preface

As the functions of front-end applications become more and more complex, state management has always been a hot topic of discussion in the front-end community. From Flux, Redux, MobX to unstated-Next and Recoli, which have been launched by Hooks, these state management libraries with different implementations are essentially designed to meet similar needs: State sharing and organizational extraction of logic.

According to different storage modes of state store, state management schemes can be divided into two types:

  • Rely on the ReactStateandContextThe React tree API is stored in the React tree, so it can only be used in React: Recoil
  • Regardless of the UI layer, the state is stored in external stores: Redux, Mobx

Or, depending on the implementation concept, it can be divided into three categories:

  • Flux: the centralizedstoreWrite to action: Redux, Zustand
  • Proxy: responds to bidirectional binding: Mobx, Valtio
  • The React tree has the same Atomic state as Recoil, Jotai

This article introduces Jotai (State), a recoil-inspired but lighter and more flexible atomic state management library. Author Daishi Kato is a famous developer in the React community. He has produced several excellent state management libraries, including Zustand, Valtio and Jotai, which are simpler and lighter than their predecessors.

What problem does the atomic formula solve?

React Hooks make state splitting and logic sharing easier, but useState + useContext still needs to maintain multiple Context providers for multiple stores. Because when the context value changes, all components consuming the context will be re-rendered, even if the component only uses part of the context, which can easily lead to unnecessary useless rendering and performance loss. Context is better for holding global data that doesn’t change much, such as topics, rather than complex collections of frequently updated states.

A simple example:

  • Define two Counter child components, A and B, that consume values of A and B in the same Context
  • The Counter component contains the time to display the render (Date.now()), the display time will change if the component is re-rendered.
import React, { useState, useContext, createContext } from "react";

const context = createContext(null);

const CounterA = () = > {
  const [value, setValue] = useContext(context);
  return (
    <div>
      <div>
        A: {value.a};<span> Time: {Date.now()}</span>
      </div>
      <button onClick={()= >setValue((prev) => ({ ... prev, a: prev.a + 1 }))}> A+1</button>
    </div>
  );
};

const CounterB = () = > {
  const [value, setValue] = useContext(context);
  return (
    <div>
      <div>
        B: {value.b};<span> Time: {Date.now()}</span>
      </div>
      <button onClick={()= >setValue((prev) => ({ ... prev, b: prev.b + 1 }))}> B+1</button>
    </div>
  );
};

const TimeC = () = > {
  return <div>TimeC: {Date.now()}</div>;
};

const initValue = {
  a: 0.b: 1
};

const Provider = ({ children }) = > {
  const [value, setValue] = useState(initValue);
  return (
    <context.Provider value={[value, setValue]} >{children}</context.Provider>
  );
};

export default function App() {
  return (
    <Provider>
      <div className="App">
        <CounterA />
        <CounterB />
        <TimeC />
      </div>
    </Provider>
  );
}

Copy the code



As you can see, whenever a Counter changes the Context, the Counter that consumes the Context is also re-rendered, and the TimeC component that acts as a comparison is not re-rendered.

Atom type

Historically, in the Context API and centralized state management schemes like Redux, all states are built from the top down of an object. In Recoil, however, the complex set of states is broken down into minor-grained Atoms, each of which can be understood as part of the Redux Store, but both incremental (which can be created on demand) and distributed (which can be created anywhere). Atom is composed, created, and updated using hooks and selector pure functions. Only components that use atom trigger re-render when Atom updates. Thus, in atomic form, high performance state sharing and code splitting can be achieved directly using apis like useState without defining template code and drastically changing the component design.

Lighter and more flexible Jotai

While Recoil’s claims of high-performance atomic state management are tempting, it’s worth noting that Reocil itself is a fairly complex design, with dozens of APIs for more complex large-scale scenarios, making it expensive to get started. In order to avoid Context API issues, Reocil uses the useRef API to store state and manage state subscriptions and updates internally. Strictly, states are not included in the React Tree, and they also face Concurrent Mode compatibility issues with external states. However, the Context API, which easily shares state in the React Tree and is naturally compatible with future Concurrent modes, is nice. React after realized performance issues community also [useContextSelecotr] (https://github.com/reactjs/rfcs/pull/119) is put forward proposal and implement of community/use – the context – the selector (HTTPS: / /github.com/dai-shi/use-context-selector) (also written by Daishi Kato), subscribes locally to the Context’s data via an additional selector.

Use-context-selector was originally used to pass back changedBits=0 in creatContext, which is not documented in the API documentation, to prevent the Provider from triggering component updates.

Is there a solution for both atomic and Concurrent Mode? Let’s look at the lighter, more flexible Jotai for Context APIS:

  • With its focus on lightweight atomic state management, the Jotai package is much smaller than Recoil (2.8KB after Gziped vs. 20.4KB). And there are only three core apis:atom.ProvideranduseAtom(Expanded capacity byjotai/utilsandjotai/devtoolsProvide)
  • In the JotaiatomInstead of using the string key in Recoil, use the Object Reference. It is more intuitive to use, but also loses the convenience of using string keys directly on debug.
  • Before provider-less Mode was introduced, Jotai’satomPut it in the React Context and use ituse-context-selectorTo avoid duplicate rendering problems.

(Github.com/pmndrs/jota…)

The simplest example:

import { atom, useAtom } from 'jotai'



/ / the original Atom
const countAtom = atom(0) 
/ / derived Atom
const doubleCountAtom = atom((get) = > get(countAtom) * 2) 
// Only Atom for update functions
const increaseTenAtom = atom(null.(get, set, _arg) = > set(countAtom, get(countAtom) + 10))

const Counter = () = > {
  const [count, setCount] = useAtom(countAtom)
  return (
    <h1>
      {count}
      <button onClick={()= > setCount(c => c + 1)}>+1</button>
    </h1>)}const DoubleText = () = > {
  const [doubleCount] = useAtom(doubleCountAtom)
  const [increase] = useAtom(increaseTenAtom)
  return (
    <h1>
      {doubleCount}
    	<button onClick={increase}>+ 10</button>
    </h1>)}const App = () = > {
 return (
 		<>
   		<DoubleText />
   		<Counter />
   	</>)}Copy the code

In Jotai, only the first argument to a single Atom API is used to create the original and derived states, the difference being that the latter argument is passed in a function to derive from another Atom. The second argument is used to generate an Atom (Writable Derived Atom and Write Only Atom) that specifies the update function. Here’s a look at Jotai in more complex real-world scenarios from one of the most common CURD scenarios (see the scenario in 7GUIs) :

  • Three controlled input boxes, alias, content and filtering.
  • A list shows the added items.
  • Three action buttons, create, Delete and update.
// atom.js
import { atom } from "jotai";

// Three input boxes of Atom
export const aliasAtom = atom("");
export const textAtom = atom("");
export const filterAtom = atom("");

// All list atom, each atom is an object containing alias and text
const itemListAtom = atom([]);

// Atom is currently selected, which contains objects for alias and text
const currentSelectAtom = atom(null);

// Derive Atom to get the filtered list
export const filteredByAliasListAtom = atom((get) = > {
  const filter = get(filterAtom);
  const list = get(itemListAtom);

  return filter
    ? list.filter((itemAtom) = > get(itemAtom).alias.includes(filter))
    : list;
});

// Derive Atom, get/set the currently selected list item Atom
export const selectItemAtom = atom(
  (get) = > get(currentSelectAtom),
  (get, set, itemAtom) = > {
    set(currentSelectAtom, itemAtom);
    if (itemAtom) {
      const{ alias, text } = get(itemAtom); set(aliasAtom, alias); set(textAtom, text); }});// Just update atom to create a new item for the list
export const createItemAtom = atom(null.(get, set) = > {
  const alias = get(aliasAtom);
  const text = get(textAtom);

  if (alias && text) {
    const itemAtom = atom({ alias, text });
    set(itemListAtom, (prev) = > [...prev, itemAtom]);
    set(aliasAtom, "");
    set(textAtom, ""); }});// Only atom is updated, and the selected atom in the list is updated
export const updateItemAtom = atom(null.(get, set) = > {
  const alias = get(aliasAtom);
  const text = get(textAtom);
  const current = get(selectItemAtom);
  if(alias && text && current) { set(current, { alias, text }); }});// Update only atom, removing the selected atom from the list
export const deleteItemAtom = atom(null.(get, set) = > {
  const current = get(selectItemAtom);
  if (current) {
    set(itemListAtom, (prev) = > prev.filter((item) = > item !== current));
  }
});

Copy the code

As you can see, Jotai prefers to keep all the state-related logic in a separate Atom, similar to the custom Hooks in React.

// app.js (part content)
const Item = ({ itemAtom }) = > {
  const [value] = useAtom(itemAtom);
  const [selected, setSelected] = useAtom(selectItemAtom);
 
  const { alias, text } = value;
  const isSelected = selected === itemAtom;

  const onSelect = () = > {
    setSelected(itemAtom);
  };

  return (
    <div
      onClick={onSelect}
      style={{ backgroundColor: isSelected ? "grey" : "#fff}} ">
      <span>{alias}</span>
      <span> - </span>
      <span>{text}</span>
    </div>
  );
};

const ItemList = () = > {
  const [list] = useAtom(filteredByAliasListAtom);

  return (
    <div>
      List
      <ul>
        {list.map((item, i) => (
          <li key={i}>
            <Item itemAtom={item} />
          </li>
        ))}
      </ul>
    </div>
  );
};

Copy the code

CodeSanbox is actually quite easy to learn from which are Hooks.

Third party Integration

While the Jotai state is stored in the React Tree, there are also integration plugins for third-party libraries that interact with external states such as Jotai/Query, Jotai/Valtio, and Jotai/Redux. Jotai/IMmer integrates imMER as a status update method.

  • jotai/reduxExample:
import { useAtom } from 'jotai'
import { atomWithStore } from 'jotai/redux'
import { createStore } from 'redux'

const initialState = { count: 0 }
const reducer = (state = initialState, action: { type: 'INC' }) = > {
  if (action.type === 'INC') {
    return { ...state, count: state.count + 1}}return state
}
const store = createStore(reducer)
// Bind the Redux Store bidirectionally, updating state from Redux or from Jotai
const storeAtom = atomWithStore(store)

const Counter = () = > {
  const [state, dispatch] = useAtom(storeAtom)

  return (
    <>
      count: {state.count}
      <button onClick={()= > dispatch({ type: 'INC' })}>button</button>
    </>)}Copy the code
  • jotai/immerExample:
import { useAtom, atom } from 'jotai'
import { atomWithImmer } from 'jotai/immer'

// Register an immer Atom with an immer update status method
const demoAtom = atomWithImmer({a: 0.b: 0})

const Display = () = > {
  const [demo] = useAtom(demoAtom)
  return <div>a: {demo.a} and b: {demo.b}</div>
}

const Updater = () = > {
	const [, setDemo] = useAtom(demoAtom)
	const onUpdate = () = > setDemo((demo) = > {
  	demo.a += 1
    demo.b += 2
  })
	return <button onClick={onUpdate}>Update!</button>
}
Copy the code

In addition to the basic immer atom, the plugin also has a useful hook to useImmerAtom to update existing atom as immer.

A debugging tool

According to the development documentation, Jotai officially provides two debugging methods:

  1. Use Redux DevTools to see specificatomState, just need to changeatomAnd the label onjotai/devtoolsIn theuseAtomDevtoolsHooks.
  2. Use React Dev Tools to view Jotai’s ProvideratomThe state is storedDebugStateMedium, but additional Settings are requiredatomDebugLabel as the key, otherwise will display as<no debugLabel>.
// 1. Redux DevTools
import { useAtomDevtools } from 'jotai/devtools'
useAtomDevtools(cuntAtom, 'label')

// 2. React Dev Tools
countAtom.debugLabel = 'label'
Copy the code

Overall, debugging for Both Jotai and Recoil requires additional configuration and apis, and there is room for improvement in the development experience.

Concurrent Mode support

As mentioned previously, the React Concurrent Mode will have compatibility issues with the external state scheme, which means that the units won’t tearing in the same rendering. Because in Concurrent Mode, the same render process is not obstructed as it used to be, but can be interrupted and resumed. At the same level, if a child component reads an external state during render, and a new event interrupts render and updates the state, subsequent child components begin render with a different state. For more details about Tearing, see this discussion in React 18. After the React 18 release plans were announced, React issued a post explaining the impact of Concurrent Mode on third-party libraries and proposed changes. There are three ways and stages to avoid tearing:

  1. React re-render Level 1: React re-render when detecting external state inconsistenciesuse-subscription). It is still possible to have temporary UI inconsistencies (first render) and the next re-render will be synchronous and not enjoy the performance and experience gains of Concurrent Mode.
  2. Level 2: re-render Level 2: re-render Level 2: re-render Level 2: re-renderuseMutableSource The proposal). This method has the benefit of not tearing at all like Level 1, but performance will still be worse than pure Concurrent Mode because of the break and re-render.
  3. Level 3: Use the React built-in state (stateandcontext“), or we can call external states “snapshots” which are immutable when they mutate. This method will always render the same UI, won’t tear, and enjoys all the features of a Concurrent Mode.

Because useMutableSource is still in the proposed state and has not been officially launched, most of the state management libraries that use external states will tearing more or less. Jotai is currently in Phase 1, but it already uses some of the useMutableSource features, so the authors say Jotai will reach Phase 2 once React launches the API. Once the phase 3 experimental approach is approved, the immutable restriction added to Jotai’s API should theoretically be perfectly compatible with Concurrent Mode.

conclusion

While Jotai is simple, it already has a React global state management feature that works well in certain scenarios. However, the community is still exploring best practices for Jotai since it has not been around for a long time. (The authors even developed Jotai-JSX, which is based on Jotai and does not rely on React for UI rendering.) Atomic is not a silver bullet that can solve all needs, and it remains to be seen whether it can be used for large projects that require long-term maintenance.