17

Im playing around with recoil for the first time and cant figure out how I can read all elements from an atomFamily. Let's say I have an app where a user can add meals:

export const meals = atomFamily({
  key: "meals",
  default: {}
});

And I can initialize a meal as follows:

const [meal, setMeal] = useRecoilState(meals("bananas"));
const bananas = setMeal({name: "bananas", price: 5});

How can I read all items which have been added to this atomFamily?

DannyMoshe
  • 6,023
  • 4
  • 31
  • 53

4 Answers4

19

Instead of using useRecoilCallback you can abstract it with selectorFamily.

// atomFamily
const mealsAtom = atomFamily({
  key: "meals",
  default: {}
});

const mealIds = atom({
  key: "mealsIds",
  default: []
});

// abstraction
const meals = selectorFamily({
  key: "meals-access",
  get:  (id) => ({ get }) => {
      const atom = get(mealsAtom(id));
      return atom;
  },
  set: (id) => ({set}, meal) => {
      set(mealsAtom(id), meal);
      set(mealIds (id), prev => [...prev, meal.id)]);
  }
});

Further more, in case you would like to support reset you can use the following code:

// atomFamily
const mealsAtom = atomFamily({
  key: "meals",
  default: {}
});

const mealIds = atom({
  key: "mealsIds",
  default: []
});

// abstraction
const meals = selectorFamily({
  key: "meals-access",
  get:  (id) => ({ get }) => {
      const atom = get(mealsAtom(id));
      return atom;
  },
  set: (id) => ({set, reset}, meal) => {
      if(meal instanceof DefaultValue) {
        // DefaultValue means reset context
        reset(mealsAtom(id));
        reset(mealIds (id));
        return;
      }
      set(mealsAtom(id), meal);
      set(mealIds (id), prev => [...prev, meal.id)]);
  }
});

If you're using Typescript you can make it more elegant by using the following guard.

import { DefaultValue } from 'recoil';

export const guardRecoilDefaultValue = (
  candidate: unknown
): candidate is DefaultValue => {
  if (candidate instanceof DefaultValue) return true;
  return false;
};

Using this guard with Typescript will look something like:

// atomFamily
const mealsAtom = atomFamily<IMeal, number>({
  key: "meals",
  default: {}
});

const mealIds = atom<number[]>({
  key: "mealsIds",
  default: []
});

// abstraction
const meals = selectorFamily<IMeal, number>({
  key: "meals-access",
  get:  (id) => ({ get }) => {
      const atom = get(mealsAtom(id));
      return atom;
  },
  set: (id) => ({set, reset}, meal) => {
      if (guardRecoilDefaultValue(meal)) {
        // DefaultValue means reset context
        reset(mealsAtom(id));
        reset(mealIds (id));
        return;
      }
      // from this line you got IMeal (not IMeal | DefaultValue)
      set(mealsAtom(id), meal);
      set(mealIds (id), prev => [...prev, meal.id)]);
  }
});
Yoav Kadosh
  • 4,807
  • 4
  • 39
  • 56
Bnaya
  • 715
  • 5
  • 7
  • What is this syntax `reset(mealIds (id));`? why is there a space b/w `mealIds` and `(id)`? @Bnaya – Venugopal May 20 '21 at 07:21
  • 1
    @Venugopal good point. I think OP accidentally used a list atom as it was an atomFamily. IMO This approach is fine as long as you manually pop the id from `mealIds` when given an instance of `DefaultValue` i.e. `set(mealIds, (oldIds) => oldIds.filter((idList) => idList !== id));` – Rainelz May 21 '21 at 14:10
  • How does usage for reset (delete) look like? – Philip Aarseth Aug 01 '21 at 12:53
  • How do you go about populating this? Say you have an array of 5k meals from an API, do you have to just run through a loop and set every single `mealsAtom` and `mealIds`? – Douglas Gaskell May 02 '22 at 23:15
17

You have to track all ids of the atomFamily to get all members of the family. Keep in mind that this is not really a list, more like a map.

Something like this should get you going.

// atomFamily
const meals = atomFamily({
  key: "meals",
  default: {}
});

const mealIds = atom({
  key: "mealsIds",
  default: []
});

When creating a new objects inside the family you also have to update the mealIds atom.

I usually use a useRecoilCallback hook to sync this together

  const createMeal = useRecoilCallback(({ set }) => (mealId, price) => {
    set(mealIds, currVal => [...currVal, mealId]);
    set(meals(mealId), {name: mealId, price});
  }, []);

This way you can create a meal by calling:

createMeal("bananas", 5);

And get all ids via:

const ids = useRecoilValue(mealIds);
Johannes Klauß
  • 10,676
  • 16
  • 68
  • 122
  • I see, so you need a separate atom to keep track of ids. Do you need snapshot here? Why should this be async? Thanks for your response. – DannyMoshe Oct 20 '20 at 21:08
  • Oh sorry, yeah no need for async or snapshot. Updated answer. – Johannes Klauß Oct 21 '20 at 12:47
  • 1
    In your example, how do you get all meals instead of all ids? – Design by Adrian Oct 18 '21 at 14:06
  • @DesignbyAdrian unless I'm mistaken, I believe that the solution is to take the value of the `mealIds` atom, an array, and then loop through it getting the value of each atom in the atom family with an id that corresponds to a value in from the array. – flyingace Mar 28 '22 at 15:55
  • @flyingace That's an expensive operation no? – Douglas Gaskell May 02 '22 at 23:15
  • @DouglasGaskell To my mind it's not only expensive, it's also overly complicated. My team switched from trying out Recoil to using Redux Tool Kit. Using `createEntityAdapter` in RTK gives you both the separate table of ids and all of the elements those ids reference in a format that is much easier to manage. It may be that there is a simpler path to get this information in Recoil, but I was unable to find it as of v6. – flyingace May 03 '22 at 13:46
  • That's what I was thinking as well, it tends to become quite complicated when it feels like it shouldn't. – Douglas Gaskell May 04 '22 at 04:00
5

You can use an atom to track the ids of each atom in the atomFamily. Then use a selectorFamily or a custom function to update the atom with the list of ids when a new atom is added or deleted from the atomFamily. Then, the atom with the list of ids can be used to extract each of the atoms by their id from the selectorFamily.

// File for managing state


//Atom Family
export const mealsAtom = atomFamily({
  key: "meals",
  default: {},
});
//Atom ids list
export const mealsIds = atom({
  key: "mealsIds",
  default: [],
});

This is how the selectorFamily looks like:

// File for managing state

export const mealsSelector = selectorFamily({
  key: "mealsSelector",
  get: (mealId) => ({get}) => {
    return get(meals(mealId));
  },
  set: (mealId) => ({set, reset}, newMeal) => {
    // if 'newMeal' is an instance of Default value, 
    // the 'set' method will delete the atom from the atomFamily.
    if (newMeal instanceof DefaultValue) {
      // reset method deletes the atom from atomFamily. Then update ids list.
      reset(mealsAtom(mealId));
      set(mealsIds, (prevValue) => prevValue.filter((id) => id !== mealId));
    } else {
      // creates the atom and update the ids list
      set(mealsAtom(mealId), newMeal);
      set(mealsIds, (prev) => [...prev, mealId]);
    }
  },
});

Now, how do you use all this?

  • Create a meal:

In this case i'm using current timestamp as the atom id with Math.random()

// Component to consume state

import {mealsSelector} from "your/path";
import {useSetRecoilState} from "recoil";
const setMeal = useSetRecoilState(mealsSelector(Math.random()));

setMeal({
    name: "banana",
    price: 5,
});

  • Delete a meal:
// Component to consume state

import {mealsSelector} from "your/path";
import {DefaultValue, useSetRecoilState} from "recoil";

const setMeal = useSetRecoilState(mealsSelector(mealId));
setMeal(new DefaultValue());
  • Get all atoms from atomFamily:

Loop the list of ids and render Meals components that receive the id as props and use it to get the state for each atom.

// Component to consume state, parent of Meals component

import {mealsIds} from "your/path";
import {useRecoilValue} from "recoil";

const mealIdsList = useRecoilValue(mealsIds);

    //Inside the return function:
    return(
      {mealIdsList.slice()
          .map((mealId) => (
            <MealComponent
              key={mealId}
              id={mealId}
            />
          ))}
    );
// Meal component to consume state

import {mealsSelector} from "your/path";
import {useRecoilValue} from "recoil";

const meal = useRecoilValue(mealsSelector(props.id));

Then, you have a list of components for Meals, each with their own state from the atomFamily.

  • 1
    How would you batch add a list of meals? Say if you were to make a call to an API that returns an array of meals. Would you loop through that data and add the videos one by one? – PGT Jul 02 '22 at 03:05
  • @PGT Yes, atoms store individual items (e.g. a meal, a video) and not lists in this approach (e.g. meals, videos), so each one needs to be processed individually through a loop or map. The benefit of this granular approach comes when modifying just one item or just some of them by preventing re-rendering of the whole collection by modifying an entire list state – TomasLangebaek Aug 09 '22 at 17:02
2

Here is how I have it working on my current project:

(For context this is a dynamic form created from an array of field option objects. The form values are submitted via a graphql mutation so we only want the minimal set of changes made. The form is therefore built up as the user edits fields)

import { atom, atomFamily, DefaultValue, selectorFamily } from 'recoil';

type PossibleFormValue = string | null | undefined;

export const fieldStateAtom = atomFamily<PossibleFormValue, string>({
  key: 'fieldState',
  default: undefined,
});

export const fieldIdsAtom = atom<string[]>({
  key: 'fieldIds',
  default: [],
});

export const fieldStateSelector = selectorFamily<PossibleFormValue, string>({
  key: 'fieldStateSelector',
  get: (fieldId) => ({ get }) => get(fieldStateAtom(fieldId)),
  set: (fieldId) => ({ set, get }, fieldValue) => {
    set(fieldStateAtom(fieldId), fieldValue);
    const fieldIds = get(fieldIdsAtom);
    if (!fieldIds.includes(fieldId)) {
      set(fieldIdsAtom, (prev) => [...prev, fieldId]);
    }
  },
});

export const formStateSelector = selectorFamily<
  Record<string, PossibleFormValue>,
  string[]
>({
  key: 'formStateSelector',
  get: (fieldIds) => ({ get }) => {
    return fieldIds.reduce<Record<string, PossibleFormValue>>(
      (result, fieldId) => {
        const fieldValue = get(fieldStateAtom(fieldId));
        return {
          ...result,
          [fieldId]: fieldValue,
        };
      },
      {},
    );
  },
  set: (fieldIds) => ({ get, set, reset }, newValue) => {
    if (newValue instanceof DefaultValue) {
      reset(fieldIdsAtom);
      const fieldIds = get(fieldIdsAtom);
      fieldIds.forEach((fieldId) => reset(fieldStateAtom(fieldId)));
    } else {
      set(fieldIdsAtom, Object.keys(newValue));
      fieldIds.forEach((fieldId) => {
        set(fieldStateAtom(fieldId), newValue[fieldId]);
      });
    }
  },
});

The atoms are selectors are used in 3 places in the app:

In the field component:

...
const localValue = useRecoilValue(fieldStateAtom(fieldId));
const setFieldValue = useSetRecoilState(fieldStateSelector(fieldId));
...

In the save-handling component (although this could be simpler in a form with an explicit submit button):

...
const fieldIds = useRecoilValue(fieldIdsAtom);
const formState = useRecoilValue(formStateSelector(fieldIds));
...

And in another component that handles form actions, including form reset:

...
const resetFormState = useResetRecoilState(formStateSelector([]));
...
const handleDiscard = React.useCallback(() => {
  ...
  resetFormState();
  ...
}, [..., resetFormState]);
  • Maybe slightly offtopic, but curious since this approach works pretty well I must say. How do you handle validation errors? – Dac0d3r Mar 22 '22 at 14:50