How to get all elements from an atomFamily in recoil?
Asked Answered
F

4

19

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?

Fenestella answered 19/10, 2020 at 19:23 Comment(0)
A
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);
Awad answered 20/10, 2020 at 20:3 Comment(7)
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.Fenestella
Oh sorry, yeah no need for async or snapshot. Updated answer.Jasminjasmina
In your example, how do you get all meals instead of all ids?Malonis
@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.Banquette
@Banquette That's an expensive operation no?Lynwoodlynx
@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.Banquette
That's what I was thinking as well, it tends to become quite complicated when it feels like it shouldn't.Lynwoodlynx
B
20

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)]);
  }
});
Bly answered 9/12, 2020 at 12:30 Comment(4)
What is this syntax reset(mealIds (id));? why is there a space b/w mealIds and (id)? @BlyMellette
@Mellette 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));Cleo
How does usage for reset (delete) look like?Peipus
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?Lynwoodlynx
A
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);
Awad answered 20/10, 2020 at 20:3 Comment(7)
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.Fenestella
Oh sorry, yeah no need for async or snapshot. Updated answer.Jasminjasmina
In your example, how do you get all meals instead of all ids?Malonis
@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.Banquette
@Banquette That's an expensive operation no?Lynwoodlynx
@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.Banquette
That's what I was thinking as well, it tends to become quite complicated when it feels like it shouldn't.Lynwoodlynx
V
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.

Videogenic answered 23/10, 2021 at 18:25 Comment(2)
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?Rankin
@Rankin 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 stateVideogenic
R
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]);
Responsibility answered 21/4, 2021 at 17:39 Comment(1)
Maybe slightly offtopic, but curious since this approach works pretty well I must say. How do you handle validation errors?Anecdotal

© 2022 - 2024 — McMap. All rights reserved.