Skip to content

Feature Request: Keyed Hooks #15893

Closed
Closed
@Nathan-Fenner

Description

@Nathan-Fenner

Do you want to request a feature or report a bug? Request a Feature

Previous Issues

"Keys for hooks" was previously proposed in #14998 and closed without much discussion.

See below ("Use Cases") for particular use cases of keyed hooks, and why they provide a better, more-general solution than other approaches today.

Background: the rules of hooks

The Rules of Hooks outline how React hooks are allowed to be called. The rules are the following:

  • Hooks can only be called at the top level from custom hooks or React components
  • The same hooks, in the same order, must be called from any component every time it renders (or every time some custom hook is called)

There are several good reasons for these rules:

  1. These rules enable the implementation of hooks to be simple (a global, incrementing counter identifies the state for each primitive hook).
  2. The rules enable the syntax of hooks to be simple (a sequence of function calls, in order, without explicitly threading state between them, and without needing to arrange them into e.g. an array)
  3. You can reason about hooks independently since they don't depend on what comes before / after them: this is what allows you to reason about custom hooks in a way that ignores their implementation
  4. The rules can be easily checked by static linters

The third rule is important because we want to be able to provide custom hooks whose implementations are "black boxes": we don't need to know how a hook works to know what it does. The only requirement is when we call custom hooks, we also follow the Rules. It's important that this change (or other changes to hooks; or the introduction of new primitive hooks) do not cause it to break.

The main limitation of these rules is that hooks must be arranged in a line. We'd like to be able to generalize to support trees of hooks. In particular, just like React identifies the state for a component by its location within its parents component tree, we'd like to be able to "relax" the rules of hooks enough that we can call hooks conditionally or variably without breaking any of (1) or (2) or (3) or (4).

Feature Overview

Allow hooks to be grouped and keyed.

The example below demonstrates correct usage of the proposed API:

import * as React from 'react';

function useLookupMultiple(letters) {
    const lookups = React.useGroup(keyer => {
        const values = [];

        const valA = keyer('A', () => {
            return useLookup('A');
        });
        values.push(valA);


        for (const letter of letters) {
            const valLetter = keyer(letter, () => {
                 return useLookup(letter);
            });
            values.push(valLetter);
        }

        const valB = keyer('B', () => {
            return useLookup('B');
        });
        values.push(valB);

        return values;
    });

    return lookups;
}

New Rules of Hooks

  • Hooks still need to follow the old rules of hooks
  • useGroup is a new, primitive hook which must also obey the existing rules of hooks
  • but hooks can be called from one new place: the keyer function callback obtained from useGroup

We'll now go through the 4 properties outlined above and see that they still apply.

Property 1: Ease of Implementation

Today, React essentially stores an array of "memory cells" for primitive hooks. A global counter is used to identify which cell is the "current" one, and each primitive hook increments this counter.

To implement useGroup, only a small detour is required:

  • The useGroup memory cell essentially holds the following state: {key1: memoryCellArray1, key2: memoryCellArray2, key3: memoryCellArray3}.
  • The passed keyer callback first replaces the global memory cell array with the one associated with the provided key; then it calls its passed callback; then it reverts the global memory cell array so that the group can continue

Property 2: Ease of Syntax

The existing hook syntax is unchanged. The new group syntax is somewhat unwieldier, but the basic concepts still apply: regular function calls (albeit inside callbacks similar to useEffect or useReducer although run synchronously within the render like the latter rather than the former). The clumsier syntax is actually a benefit, because this feature should be seldom used, except for making certain custom hooks more powerful (see below for the intended use-cases).

Property 3: Independence

Hooks remain independent from one another. Moreover, since hooks are clearly grouped as well as simply keyed, it's easy to tell the scope of keys; in particular, there's no (sensible) way to split a keyed group of hooks across multiple custom hooks (the groups form a clear hierarchy bounded by custom hook scopes), so they can still be understood completely independently.

Property 4: Easy Linting

Linters would need to be updated in order to support this feature. Luckily, it's fairly easy! The same rules of hooks apply, except that hooks may also be called (at the top level of) the keyer callback argument obtained from React.useGroup.

Use Case: The Problem Today

The simplest use-case that demonstrates why keyed hooks would be useful is outlined here. The EventSource API allows JavaScript to stream events. We can write a nice custom hook to subscribe to an endpoint in some component:

export function useEventSourceListener(url, listenCallback) {
  const listenCallbackRef = React.useRef(listenCallback);

  React.useEffect(() => {
    // On re-render, the listener should be updated.
    listenCallbackRef.current = listenCallback;
  });

  React.useEffect(() => {
    const source = new EventSource(url);
    source.onmessage = e => {
      listenCallbackRef.current(e.data);
    };
    return () => {
      source.close();
    };
  }, [url]);
}

Using it is pretty straight-forward:

const ExampleComponent = ({id}) => {
    const [messages, setMessages] = React.useState([]);

    useEventSourceListener(`https://example.com/stream/${id}`, newMessage => {
        setMessages(current => current.concat([newMessage]);
    });

    return <ul>{messages.map((msg, index) => <li key={index}>{msg}</li>)}</ul>;
}

This hook worked great for me, until I realized that a particular event stream that I needed was sharded across multiple URLs!

const ExampleComponent = ({id}) => {
    const [messages, setMessages] = React.useState([]);

    const onNewMessage = newMessage => {
        setMessages(current => current.concat([newMessage]);
    };

    useEventSourceListener(`https://example.com/stream/${id}/shard-1`, onNewMessage);
    useEventSourceListener(`https://example.com/stream/${id}/shard-2`, onNewMessage);

    return <ul>{messages.map((msg, index) => <li key={index}>{msg}</li>)}</ul>;  
}

And now this works until it turns out that there are a dynamic number of URLs depending on id. Once that happens, you can't write this using React unless you're willing to very carefully violate the rules of hooks and also give up on certain maintainability benefits. For example, the following solution "obeys the rules of hooks" as far as React can tell at runtime (although it will fail decent lint checks):

const ExampleComponent = ({id, shards}) => {
    return <ExampleComponentInternal key={shards.length} id={id} shards={shards} />
};

const ExampleComponentInternal = ({id, shards}) => {
    const [messages, setMessages] = React.useState([]);

    const onNewMessage = newMessage => {
        setMessages(current => current.concat([newMessage]);
    };

    for (const shard of shards) {
        useEventSourceListener(`https://example.com/stream/${id}/${shard}`, onNewMessage);
    }
    return <ul>{messages.map((msg, index) => <li key={index}>{msg}</li>)}</ul>;  
}

of course, this solution has a large number of problems:

  • if the number of shards changes, then any other state (e.g. forms) stored inside the component get thrown away, since the key changed
  • if the shards get reordered, then the connections will be dropped and re-instantiated, since each useEventSourceListener call only knows about itself!

These problems can be remedied in turn by the following solution:

const ListenerComponent = ({url, onNewMessage}) => {
    useEventSourceListener(url, onNewMessage);
    return null;
}

const ExampleComponent = ({id, shards}) => {
    const [messages, setMessages] = React.useState([]);

    const onNewMessage = newMessage => {
        setMessages(current => current.concat([newMessage]);
    };

    for (const shard of shards) {
        useEventSourceListener(, onNewMessage);
    }
    return <>
        {shards.map(shard => <ListenerComponent url={`https://example.com/stream/${id}/${shard}`} key={shard} />)}
        <ul>{messages.map((msg, index) => <li key={index}>{msg}</li>)}</ul>
    </>;
}

This solution finally works in React today. Unfortunately, that stops being true if instead useEventSourceListener returns any useful value: there's no way to (synchronously) pass a returned value up from those ListenerComponents into ExampleComponent. Using refs and useLayoutEffect you can kinda fake this, but in particular there's no way to pass those values into subsequent hooks, because hooks can't be called inside useEffect or useLayoutEffect callbacks.

Use Case: The Solution

Using keyed hooks, we simply get:

const ExampleComponent = ({id, shards}) => {
    const [messages, setMessages] = React.useState([]);

    const onNewMessage = newMessage => {
        setMessages(current => current.concat([newMessage]);
    };

    React.useGroup(keyer => {
        for (const shard of shards) {
            keyer(shard, () => {
                useEventSourceListener(`https://example.com/stream/${id}/${shard}`, onNewMessage);
           });
        }
    });
    return <>
        <ul>{messages.map((msg, index) => <li key={index}>{msg}</li>)}</ul>
    </>;
}

Just like components, we only need to distinguish our hooks calls among siblings with keyer. Since useEventSourceListener is itself already robust against changes to the URL, we don't need to include id inside keyer's key argument.

General Rationale

The general rationale behind this approach is to provide better composition of custom hooks. One workaround for the above approach is to create a new useMultipleEventSourceListener(urls, callback), but this approach has several major drawbacks:

  • First, the implementation is very complicated. The main appeal of hooks like useEffect is that they encapsulate all aspects of one feature: initialization and cleanup go together. Implementing useMultipleEventSourceListener, while possible, doesn't let us take advantage of this because we're stuck with doing all initialization / teardown / diffing of values ourselves, since React can no longer do it for us.

  • Second, it's not compositional. Why should I have to re-implement a complex function when a simple loop ought to suffice? In particular, it's at least plausible to implement useMultipleEventSourceListener because here I've written the original myself, but if the custom hook comes from some third party then it's no longer feasible to fork and implement a "multiple" version myself

  • Lastly, it makes nesting custom hooks inside each other non-compositional. Even if I implement and maintain a useMultipleEventSourceListener, if I want to call that hook multiple times, I'm out of luck; instead I need Consider the case where I find I have multiple calls to useMultipleEventSourceListener; in order to be able to handle that, I would need to be able to

Semantics in Detail

I glossed over several minor semantic details that need to be covered:

What happens to useState/useReducer when their keys change?

If the key is "new" (i.e. was not present on the previous render) then the state is copied from the "initial" argument.

If the key is not "new" (i.e. it was present on the previous render) then the state is whatever was previously stored.

Basically, if a key disappears and reappears later, the old state was lost. This is just like how components with key props behave (whether they're using class state or functional useState).

What happens to useEffect/useLayoutEffect when keys change?

If the key ceases to exist, then the cleanup code gets called (just like if the component unmounted).

This is simple, consistent, and easily understood. Again, it's just like putting the hooks inside children with key props that disappear. If hooks work in those situations, they'll likely work here as well.

What happens to useRef when keys change?

The reference is fresh every time the key is "new". In particular, if a key disappears and reappears, the old current value is lost.

What happens to useContext?

Nothing, useContext doesn't actually need memory cells at all.

What happens to useDebugValue?

The debug value can be listed alongside the key which it lies under.

What happens if the order of keys changes?

Not much - since hooks are independent of each other (property 3 above) it doesn't (or shouldn't) matter what order they're called as long as the memory cell array is updated appropriately. The call order should always match the order they're called inside useGroup.

What happens if the same key is used twice?

There are two obvious approaches:

  • Make this an error (or an error-in-debug mode, much like having multiple children with the same key prop today)
  • Make subsequent calls a no-op

The latter option is occasionally more convenient but potentially very unsafe. The former is much better (especially because if it errors today, it can be changed to have alternative behavior in the future).

What happens if you call hooks inside useGroup but not inside a keyer callback?

This causes an implementation-defined error, just like other misuses of keys or hooks today. The rules of hooks let you call hooks inside keyer callbacks, but not inside the useGroup callback.

What happens if keyer escapes from the useGroup callback and gets called later?

This causes a best-effort implementation-defined error, just like other misuses of hooks today (e.g. as if you call useState inside of an event callback).

Conclusion

React hooks are really nice 🥇. There's just a few edge cases like the above where they don't quite cut it - providing keyed hooks would just make writing and using custom hooks that much nicer, and solve tons of issues that are currently just-out-of-reach in maintainable, easily-understood ways.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Resolution: StaleAutomatically closed due to inactivity

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions