React with Recoil state management

Installing recoil

npm install recoil

Add RecoilRoot to your root component

import React from 'react';
import {RecoilRoot} from 'recoil';

function App() {
  return (
    <RecoilRoot>
      <YourComponent />
    </RecoilRoot>
  );
}

Atoms

An atom

  • is (a piece) of state that can shared between your components
  • is what components can use to read from or write to
  • will trigger a re-render for any component (that uses the atom) whenever the atom state changed

You create atom shared state like that:

const fontSizeState = atom({
  key: 'fontSizeState', // key needs to be globally unique
  default: 14,
});

Recoil provides several hooks to interact with atoms:

  • useRecoilValue(myAtom) will read the state
  • useSetRecoilState(myAtom) will set new state
  • useRecoilState(myAtom) will return two things: the atom’s state and a state setter function

Use useRecoilState to “subscribe” your Component and to read from or write to an atom:

// Component A
function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);

  /* if you only need to set value
  const setFontsize = useSetRecoilState(fontSizeState);
  setFontsize(18)
  // or alternatively to obtain old value
  setFontsize((oldFontsize) => oldFontsize + 2);
  */
  
  // if you only need to get value
  // const fontSize = useRecoilValue(fontSizeState);

  return (
    <button onClick={() => setFontSize((size) => size + 1)} style={{fontSize}}>
      Click to Enlarge
    </button>
  );
}
// Component B will receive the new value when button was clicked in Component A

function Text() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  return <p style={{fontSize}}>This text will increase in size too.</p>;
}

Selectors

Selectors are pure functions that you use to read state which was derived/transformed either sync or async from other state, for example filtered lists or the total numbers of objects that some state contains.

When creating selectors keep in mind that they represent idempotent/pure functions: For a given set of inputs they should always produce the same results, because they may be cached, restarted, or executed multiple times.

Selectors are used like selector(input)where input is either an atom or another selector. When this input is updated, the selector function will be re-evaluated too.

This is how you create a readable selector:

const fontSizeLabelState = selector({
  key: 'fontSizeLabelState',
  get: ({get}) => {
    const fontSize = get(fontSizeState); // here we get state from the atom
    const unit = 'px';

    return `${fontSize}${unit}`;
  },
});

Additionally to the selectors’ get you can define a set for the selector. This is how you create a writable selector:

const proxySelector = selector({
  key: 'ProxySelector',
  get: ({get}) => ({...get(myAtom), extraField: 'hi'}),
  set: ({set}, newValue) => set(myAtom, newValue),
});

Finally, components can “subscribe” to selectors to read or – if the selector has a set method – to set state:

function FontButton() {
  const [fontSize, setFontSize] = useRecoilState(fontSizeState);
  const fontSizeLabel = useRecoilValue(fontSizeLabelState);

  return (
    <>
      <div>Current font size: {fontSizeLabel}</div>

      <button onClick={() => setFontSize(fontSize + 1)} style={{fontSize}}>
        Click to Enlarge
      </button>
    </>
  );
}

A common pattern is to use an atom to represent local editable state, but use a selector to query default values:

const currentUserIDState = atom({
  key: 'CurrentUserID',
  default: selector({
    key: 'CurrentUserID/Default',
    get: () => myFetchCurrentUserID(),
  }),
});

Async data

Create selector using async/await:

const currentUserNameQuery = selector({
  key: 'CurrentUserName',
  get: async ({get}) => {
    const response = await myDBQuery({
      userID: get(currentUserIDState),
    });
    return response.name;
  },
});

Then use the data

function CurrentUserInfo() {
  const userName = useRecoilValue(currentUserNameQuery);
  return <div>{userName}</div>;
}

But one more important thing: Since React render functions are synchronous, what will it render before the promise resolves? There are two options

Option 1: You wrap your component in a Suspense boundary which will catch any descendants that are still pending and render a fallback UI

function MyApp() {
  return (
    <RecoilRoot>
      <React.Suspense fallback={<div>Loading...</div>}>
        <CurrentUserInfo />
      </React.Suspense>
    </RecoilRoot>
  );
}

Option 2: You use useRecoilValueLoadable

function UserInfo({userID}) {
  const userNameLoadable = useRecoilValueLoadable(userNameQuery(userID));
  switch (userNameLoadable.state) {
    case 'hasValue':
      return <div>{userNameLoadable.contents}</div>;
    case 'loading':
      return <div>Loading...</div>;
    case 'hasError':
      throw userNameLoadable.contents;
  }
}

Query based on parameters

Use selectorFamily if you need to make an async request that uses a custom parameter (here userID):

const userNameQuery = selectorFamily({
  key: 'UserName',
  get: userID => async () => {
    const response = await myDBQuery({userID});
    if (response.error) {
      throw response.error;
    }
    return response.name;
  },
});

Concurrent requests

Let’s assume you have an array containing friend objects and you want to retrieve all friends of that list by their id. To make these requests concurrently you use waitForAll:

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friends = get(waitForAll(
      friendList.map(friendID => userInfoQuery(friendID))
    ));
    return friends;
  },
});

or waitForNone:

const friendsInfoQuery = selector({
  key: 'FriendsInfoQuery',
  get: ({get}) => {
    const {friendList} = get(currentUserInfoQuery);
    const friendLoadables = get(waitForNone(
      friendList.map(friendID => userInfoQuery(friendID))
    ));
    return friendLoadables
      .filter(({state}) => state === 'hasValue')
      .map(({contents}) => contents);
  },
});

Pre-fetch data

For performance reasons you may wish to kick off fetching before rendering. You achieve this via useRecoilCallback:

function CurrentUserInfo() {
  const currentUser = useRecoilValue(currentUserInfoQuery);
  const friends = useRecoilValue(friendsInfoQuery);

  const changeUser = useRecoilCallback(({snapshot, set}) => userID => {
    snapshot.getLoadable(userInfoQuery(userID)); // pre-fetch user info
    set(currentUserIDState, userID); // change current user to start new render
  });

  return (
    <div>
      <h1>{currentUser.name}</h1>
      <ul>
        {friends.map(friend =>
          <li key={friend.id} onClick={() => changeUser(friend.id)}>
            {friend.name}
          </li>
        )}
      </ul>
    </div>
  );
}

AtomFamily

An Atom Family represents a collection of atoms. When you call atomFamily it will return a function which provides the RecoilState atom based on the parameters you pass in.

const elementPositionStateFamily = atomFamily({
  key: 'ElementPosition',
  default: [0, 0],
});

function ElementListItem({elementID}) {
  const position = useRecoilValue(elementPositionStateFamily(elementID));
  return (
    <div>
      Element: {elementID}
      Position: {position}
    </div>
  );
}

atomFamily allows you to use params for default values:

const myAtomFamily = atomFamily({
  key: ‘MyAtom’,
  default: param => defaultBasedOnParam(param),
});

Post data with custom React hook

In this example we create a custom React hook useRegister(username) that will post a username to an API. We set the response via useSetRecoilState within the hook which allows a component to set the value without subscribing the component to re-render when the value changes.

const useRegister = () => {
  const setAccount = useSetRecoilState(accountState);

  const register = useCallback(async (username)=> {
    const response = await fetch(url, {
      method: 'POST',
      body: { username },
    });

    const responseJson = await response.json();
    setAccount(responseJson);
  }, [setAccount]);

  return register;
}

const RegisterForm = () => {
  const register = useRegister();

  const handleSubmit = async (e) => {
    e.preventDefault();

    await register(username);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" name="username" />
      <button type="submit">Register</button>
    </form>
  );
}

About Author

Mathias Bothe To my job profile

I am Mathias from Heidelberg, Germany. I am a passionate IT freelancer with 15+ years experience in programming, especially in developing web based applications for companies that range from small startups to the big players out there. I create Bosycom and initiated several software projects.