facebookexperimental / Recoil

Recoil is an experimental state management library for React apps. It provides several capabilities that are difficult to achieve with React alone, while being compatible with the newest features of React.
https://recoiljs.org/
MIT License
19.6k stars 1.19k forks source link

Is there a recommended way to put atoms into an atomFamily #658

Closed otakustay closed 3 years ago

otakustay commented 4 years ago

I was recently discovering recoil to manage an entity store like redux with normalization, I think atomFamily is a best to manage entities identified by a unique key:

const todosState = atomFamily({key: 'entities/todo', default: null});

I then encountered an issue where I have already fetched a list of todos and want to put them into this atom family:

const todosState = atomFamily({key: 'entities/todo', default: null});
// Containes ids of todo for current list
const todoKeysState = atom({key: 'todo/keys', default: []});

const TodoItem = ({id}) => {
    const todo = useRecoilValue(todosState(id));

    return (
        <li>
            {todo.text}
        </li>
    );
};

const TodoList = () => {
    const [todoKeys, setTodoKeys] = useRecoilState(todoKeysState);
    useEffect(
        ()  => {
            (async () => {
                const todos = await fetch('/api/todos');
                setTodoKeys(todos.map(t => t.id));
                for (const todo of todos) {
                    // How to put this into todosState?
                }
            })();
        },
        []
    );

    return (
        <ul>
            {todoKeys.map(k => <TodoItem key={k} id={k} />)}
        </ul>
    )
};

Although I can fallback to use a single atom to store all todos and use selectorFamily to reach each todo item, its performance can't satisfy me.

otakustay commented 4 years ago

Now I introduce a Map object to store initial values of each particular atom, use a default option to link atomFamily and this map:

import {atomFamily, RecoilState} from 'recoil';

interface EntityStore<E, K = string> {
    name: string;
    initial: Map<K, E>;
    family: (key: K) => RecoilState<E>;
}

export function createEntityStore<E, K = string>(name: string): EntityStore<E, K> {
    const initial = new Map<K, E>();
    return {
        name,
        initial,
        family: atomFamily<E, K>({
            key: `entities/${name}`,
            default: (key) => initial.get(key),
        }),
    };
}

I don't think this s a best practice, is there any recommended way to manage entities inside atomFamily so that we can:

  1. put new items into it, keyed by a primitive id
  2. find a single item by key
  3. find multiple items from a key list
  4. update and delete a single item, delete can be done via a deleted flag
drarmstr commented 3 years ago

"Deleting" items from an atomFamily() container can be done using useResetRecoilState() hook. In your example I don't see that initial is ever initializing or passing in the default values. I guess it could be set later, but safer to guarantee it's set before the atomFamily() attempts to reference it for default values. What other issues are you having?

otakustay commented 3 years ago
  1. I fetched a list of items from a remote endpoint
  2. For each item in this list, I need to create an atom inside atomFamily named itemsFamily
  3. Then I render an Item component for each item key, inside component it tries to get the item via useRecoilValue(itemsFamily(props.id)) hook
  4. Item component can also fetch new value of its owned item and update corresponding atom state via useSetRecoilState(itemsFamily(props.id))

For now I can't figure a good way to archive step 2, I use initial Map to store initial item values so that a non-existing atom can reference this from Map in step 3

drarmstr commented 3 years ago

Atoms in an atomFamily() are created on first-use. So, you can set the value of the atom when parsing the query if you're handling that imperatively, e.g.:

  set(itemsFamily(props.id), valueFromQuery);

If you want to hook it up so the items automatically query for their default values and have a pending state while the query is pending, you could use a selectorFamily() as the default:

// query that returns an object of Item IDs to initial values
const initialItemValuesQuery = selector({
  key: 'InitialItemValues',
  get: ({get}) => ...fetch object of initial item values...
});

const itemsFamily = atomFamily({
  key: 'Items',
  default: selectorFamily({
    key: 'Items/Default',
    get: id => ({get}) => get(initialItemsQuery).id,
  }),
});
otakustay commented 3 years ago
  set(itemsFamily(props.id), valueFromQuery);

How can we implement this set function? Since useSetRecoilValue is a hook that cannot be called in loop or async callback, imperative set seems impossible to me

drarmstr commented 3 years ago
  set(itemsFamily(props.id), valueFromQuery);

How can we implement this set function? Since useSetRecoilValue is a hook that cannot be called in loop or async callback, imperative set seems impossible to me

You can use set's in a useRecoilCalback() in a loop or async.

const setListItems = useRecoilCallback(({set}) => listItems => {
  for (const item of listItems) {
    set(itemsFamily(item.id), item);
  }
});
theahura commented 3 years ago

@drarmstr When I try to set setListItems like so:

setListItems([{id: 'a'}, {id: 'b'}, {id: 'c'}])

listItems is a Snapshot object. What am I missing?

drarmstr commented 3 years ago

@drarmstr When I try to set setListItems like so:

setListItems([{id: 'a'}, {id: 'b'}, {id: 'c'}])

listItems is a Snapshot object. What am I missing?

Can you provide more context or a codesandbox.io example?

theahura commented 3 years ago

What seemed to work for me was

const setListItems = useRecoilCallback(({set}) => (listitems) => {
  for (const item of listItems) {
    set(itemsFamily(item.id, item));
  }
});

But was this just a typo above? Or is this a version difference? Or something else?

I'll try and get a code sandbox up, probably tomorrow

drarmstr commented 3 years ago

Ah, yes, just a typo. Thanks!

kentr commented 3 years ago

In case anyone else gets here from an online search:

I think the code just above still has some typos. This is what worked for me.

const setListItems = useRecoilCallback(({ set }) => (listItems) => {
  for (const item of listItems) {
    set(itemsFamily(item.id), item);
  }
}, []);

CodeSandbox

standuprey commented 1 year ago

Atoms in an atomFamily() are created on first-use. So, you can set the value of the atom when parsing the query if you're handling that imperatively, e.g.:

  set(itemsFamily(props.id), valueFromQuery);

If you want to hook it up so the items automatically query for their default values and have a pending state while the query is pending, you could use a selectorFamily() as the default:

// query that returns an object of Item IDs to initial values
const initialItemValuesQuery = selector({
  key: 'InitialItemValues',
  get: ({get}) => ...fetch object of initial item values...
});

const itemsFamily = atomFamily({
  key: 'Items',
  default: selectorFamily({
    key: 'Items/Default',
    get: id => ({get}) => get(initialItemsQuery).id,
  }),
});

I think

get: id => ({get}) => get(initialItemsQuery).id,

should be

get: id => ({get}) => get(initialItemsQuery)[id],

FYI expanded on the previous code sandbox to include the use of atomFamily to implement the example according to this comment

Code Sandbox