20-Dec

React

Jotai: Build Your State with Atom Precision

Have you ever felt frustrated managing the state of your application? Common challenges include unnecessary re-renders, prop-drilling, or a lot of boilerplate to manage even simple states. Jotai provides an elegant and simple solution to a lot of these challenges, building a easy, boilerplate-free and global state using atoms.

4 min read

·

By Kristina Skåtun

·

December 20, 2023

Why jotai?

Jotai has only been around since 2021, but is already seing a lot of traction. It is simple, light-weight, and integrates nicely with React state and other frameworks, making it very flexible. The use of TypeScript syntax is an added bonus.

Best of all is that the state is global and easily accessed and updated, and a component will only re-render when a state it subscribes to is updated. This is huge compared to React context, which will re-render every component that subscribes to it.

Readers familiar with Recoil will see some similarities with the use of atoms. However, as Recoil has slowed its development (only one release in the past year) and still does not have a stable release, Jotai is preferable to use with its continuous development and community support.

State management with Jotai is of particular advantage with client heavy projects, shared state across sibling components, and where re-renders are expensive. In the following sections, I will go through some of the most common functionality I have used so far, but check out the documentation for a more comprehensive overview.

Getting startet

To install, simply add Jotai to you project.

# npm
npm i jotai

# yarn
yarn add jotai

Primitive atoms

Jotai uses atoms with a bottom-up approach to build state. Primitive atoms are the simplest type, and can store any data you like (eg numbers, string, objects, arrays), much like Reacts useState.

To create an atom, simply create a variable to be an atom with a default value. Types can be added if wanted.

import { atom } from 'jotai'

const userIdAtom = atom<number | undefined>(undefined)
const countAtom = atom<number>(0)
const countriesAtom = atom<string[]>(["Norway", "Costa Rica"])

To give an example, lets say we want to build a site where users can evaluate different books. First, we would create an atom with a default value. Then, to use the atom, we can simply import it directly into a component with useAtom. This hook can now be used in the same way as for useState, except the atom can be accessed from any component you want.

interface Book {
    name: string;
    category: string;
    score: number;
}
const booksAtom = atom<Book[]>([{ name: "A Christmas Carol", category: "Christmas", score: 5 }]);

const Component = () => {
    const [books, setBooks] = useAtom(booksAtom);
    const newBook = { name: "How the Grinch Stole Christmas!", category: "Christmas", score: 6 };

    return (
        <div>
            {books.map((book) => (
                <li key={book.name}>{book.name}</li>
            ))}
            <button onClick={() => setBooks([...books, newBook])}>Add book</button>
        </div>
    );
};

There are three different options for accessing an atom in a component:

const [books, setBooks] = useAtom(booksAtom) //both read and write 
const books = useAtomValue(booksAtom) //read only
const setBooks = useSetValue(booksAtom) //write only 

Atom with storage

If you need to store a state in LocalStorage, Jotai will provide an easy option using atomWithStorage. Provide a unique key and a default value to initialise the atom. If the value exists in storage, it will be returned, and if not, the default value will be provided. Every time an atomWithStorage is updated, the value is automatically updated in the local storage as well.

const booksAtom = atomWithStorage<Books[]>("bookKey", []) 

Atom with reset

Sometimes a state needs to be reset to the default value. In some cases it is simple enough to apply useSetValue and reset the default, however, for larger objects it would be preferable to use atomWithReset. Lets say our book site has some filter categories that can be selected, with an option to reset all choices.

The atom can be reset to default in two ways; either with useResetAtom, or the standard setting function with RESET as the value.

interface BookFilters {
  category: string,
  selected: boolean
}

const defaultBookFilters = [
  {
    category: "Christmas",
    selected: true
  },
  {
    category: "Crime",
    selected: false
  },
    {
    category: "Sci-Fi",
    selected: false
  }
]

const bookFilterAtom = atomWithReset(defaultBookFilters)

const Component = () => {
  const resetBookFilters = useResetAtom(bookFilterAtom)
  const setBookFilters = useSetAtom(bookFilterAtom) 

  return (
    <>
      <button onClick={resetBookFilters}>Reset with useResetAtom</button>
      <button onClick={() => setBookFilters(RESET)}>Reset with useSetAtom</button>
    </>
  )
}

Derived atoms

Derived atoms can be used to get and/or set other atoms, with the options of altering or combining their values. Examples can be filtering a state based on some criteria, calculating the length of an array, changing casing of a string, or applying a discount to a price.

Derived atoms have a read and write option, either of which can be null if not needed. A derived atoms cannot read or write to itself, but only from/to other atoms.

const derivedAtom = atom
  (get) => get(primitiveAtom), //read is provided first, and can use get
  (get, set, update) => { //write is the second argument, and can use get, set, and update (meaning the next value(s) to be updated). 
  }

Read-only atom

For our book site, lets say we want to only show the books with a good score. A derived atom can do this for you, by adding a filter to the booksAtom:

const favouriteBooksAtom = atom<Book[]>((get) => get(booksAtom).filter((book) => book.score > 5));

In the same way, a derived atom (lets call it filteredBooksAtom) can combine the bookFiltersAtom with the booksAtom to only return books with the selected filters. The advantage is that each time either atom is updated, the filteredBooksAtom will also update its value, and all the components reading it will be reloaded.

const filteredBooksAtom = atom<Book[]>((get) => {
    const selectedBookCategories = get(bookFilterAtom)
        .filter((bookFilter) => bookFilter.selected)
        .map((filter) => filter.category);
    const books = get(booksAtom);
    return books.filter((book) => selectedBookCategories.includes(book.category));
});

Read and write atom

Read and write atom can be used to set one or several other atoms. Examples include resetting several atoms to their default at the same time, or setting a discount to a price atom. It is also possible to set timeouts. For our book site, we might want to give some holiday cheer every time a button is clicked (eg for adding a new book). A user would then receive a message that would disappear after a specified or default time.

const holidayCheerAtom = atom<string | undefined>(undefined);

export const holidayCheerWithTimeoutAtom = atom<
    string | undefined, // the type returned from the atom
    [nextValue: string, timeout?: number], // the types of the update values passed to the write function, must be an array
    void //return value of the write function, usually void
>(
    (get) => get(holidayCheerAtom),
    (get, set, nextValue: string, timeout?: number) => {
        set(holidayCheerAtom, nextValue);
        setTimeout(() => {
            set(holidayCheerAtom, undefined);
        }, timeout ?? 10000);
    },
);                 

const Component = () => {
  [holidayCheer, setHolidayCheer] = useAtom(holidayCheerWithTimeoutAtom)

  return (
...
  <button onClick={() => (setHolidayCheer("Happy holidays", 3000))}
    {holidayCheer && (
      <h5>{holidayCheer}</h5>
    )}
...
  )
}

This sums up some of the main uses of Jotai. Checkout the documentation for further uses, and enjoy an easier way of managing your state!