A Simple, decentralized state management for React
Reactish-State
Simple, decentralized state management for React.
Install
npm install reactish-state
or yarn add reactish-state
Quick start
We begin by creating some states
import { state } from "reactish-state";
// `state` can hold anything: primitives, arrays, objects...
const countState = state(0);
const todos = state([
{ task: "Shop groceries", completed: false },
{ task: "Clean the house", completed: true }
]);
// Update state
countState.set(10);
// Read from state
console.log(countState.get()); // Print 10
A state can also have actions bound to it
const countState = state(0, (set, get) => ({
// Set a new state
reset: () => set(0),
// or using the functional update of `set`
increase: () => set((count) => count + 1),
// State can still be read using `get`
decrease: () => set(get() - 1)
}));
// Using the actions
countState.actions.increase();
selector
can create derived states
import { selector } from "reactish-state";
// Derive from another state
const doubleSelector = selector(countState, (count) => count * 2);
// Can also derive from both states and selectors
const tripleSelector = selector(
countState,
doubleSelector,
(count, double) => count + double
);
A selector will re-compute only when any of the states it depends on have changed.
Use the state and selector in your React components
You can read state and selector for rendering with the useSnapshot
hook, and write to state with set
or actions. Rule of thumb: always read from useSnapshot
in render function, otherwise use the get
method of state or selector.
import { useSnapshot } from "reactish-state";
const Example = () => {
const count = useSnapshot(countState);
const triple = useSnapshot(tripleSelector);
return (
<h1>
{count} {triple}
{/* Update state using the actions bound to it */}
<button onClick={() => countState.actions.increase()}>Increase</button>
{/* Or update state using the `set` method directly */}
<button onClick={() => countState.set((i) => i - 1)}>Decrease</button>
<button onClick={() => countState.set(0)}>Reset</button>
</h1>
);
};
The component will re-render when states or selectors have changed. No provider or context are needed!
Why another state management library?
The state management solutions in the React ecosystem have popularized two state models:
-
Centralized: a single store that combines entire app states together and slices of the store are connected to React components through selectors. Examples: react-redux, Zustand.
-
Decentralized: consisting of many small states which can build up state dependency trees using a bottom-up approach. React components only need to connect with the states that they use. Examples: Recoil, Jotai.
This library adopts the decentralized state model, offering a Recoil-like API, but with a much simpler and smaller implementation(similar to Zustand), which makes it the one of the smallest state management solutions with gzipped bundle size less than 1KB.
State model | Bundle size | |
---|---|---|
Reactish-State | decentralized | |
Recoil | decentralized | |
Jotai | decentralized | |
React-Redux | centralized | |
Zustand | centralized |
Why decentralized state management?
Centralized state management usually combines the entire app states into a single store. To achieve render optimization, selectors are used to subscribe React components to slices of the store. Taking the classic Redux todo example, the store has the following shape:
{
visibilityFilter: "ALL", // ALL, ACTIVE, COMPLETED
todos: [{ task: "Shop groceries", completed: false } /* ...more items */]
}
We have a <Filter/>
component that connects to the store with a selector (state) => state.visibilityFilter
.
When any action updates the todos
slice, the selector in the <Filter/>
component needs to re-run to determine if a re-rendering of the component is required. This is not optimal as <Filter/>
component should not even be bothered when the todos are added/removed/updated.
In contrast, decentralized state management may approach the same problem with two separate states:
const visibilityFilter = state("ALL"); // ALL, ACTIVE, COMPLETED
const todos = state([
{ task: "Shop groceries", completed: false }
/* ...more items */
]);
An update of todos
, which is localized and isolated from other states, does not affect the components connected to visibilityFilter
and vice versa.
The difference might sound insignificant, but imaging every single state update could cause every selector in every component in the entire app to run again, it suggests that decentralized state model scales better for large apps. In addition, some other benefits such as code-splitting are made easier by this state model.
Why this over Zustand?
- State updates localized and isolated from other irrelevant states.
- No potential naming conflicts among states/actions within the big store.
- No need to use a React Hook to extract actions from the store.
- Actions come from outside React and no need to add them into the
useCallback/useEffect
dep array.
✨Highlights✨
- Decentralized state management
- Un-opinionated and easy-to-use API
- No need of wrapping app in Context or prop drilling
- React components re-render only on changes
- Compatible with React 18 concurrent rendering
- Selectors are memoized by default
- Feature extensible with middleware or plugins
- States persistable to browser storage
- Support Redux dev tools via middleware
- Less than 1KB: simple and small
Recipes
States should be updated immutably
import { state } from "reactish-state";
const todosState = state([{ task: "Clean the house", completed: true }]);
todosState.set((todos) => [
...todos,
{ task: "Shop groceries", completed: false }
]);
You can use the immer
package to reduce boilerplate code:
import produce from "immer";
todosState.set(
produce((todos) => {
todos.push({ task: "Shop groceries", completed: false });
})
);
Or, simply use the immer middleware.
Selectors are memoized
Selector has an API that is similar to the reselect package. You pass in one or more "input" states or selectors, and an "output" selector function that receives the extracted values and should return a derived value. The return value is memoized so that it won't cause React components to re-render even if non-primitive value is returned.
import { selector } from "reactish-state";
// Return a number
const totalNumSelector = selector(todosState, (todos) => todos.length);
// Return a new array
const completedTodosSelector = selector(todosState, (todos) =>
todos.filter((todo) => todo.completed)
);
// Return an object
const todoStats = selector(
totalNumSelector,
completedTodosSelector,
(totalNum, completedTodos) => ({
completedNum: completedTodos.length,
percentCompleted: (completedTodos.length / totalNum) * 100
})
);
Async state updates
Just call set
when you're ready:
const todosState = state([]);
async function fetchTodos(url) {
const response = await fetch(url);
todosState.set(await response.json());
}
You can also create async actions bound to a state:
const todosState = state([], (set) => ({
fetch: async (url) => {
const response = await fetch(url);
set(await response.json());
}
}));
Accessing other state or selector inside actions
You might not need it, but nothing stops you from reading or writing to other state inside an action.
const inputState = state("New item");
const todosState = state(
[{ task: "Shop groceries", completed: false }],
(set) => ({
add: () => {
set((todos) => [...todos, { task: inputState.get(), completed: false }]);
inputState.set(""); // Reset input after adding a todo
}
})
);
Interacting with state or selector outside React
const countState = state(0);
const tripleSelector = selector(countState, (count) => count * 3);
// Get a non-reactish fresh value
const count = countState.get();
const triple = tripleSelector.get();
// Listen to updates
const unsub1 = countState.subscribe(() => console.log(countState.get()));
const unsub2 = tripleSelector.subscribe(() =>
console.log(tripleSelector.get())
);
// Update `countState`, will trigger both listeners
countState.set(10);
// Unsubscribe listeners
unsub1();
unsub2();
The only difference between state and selector is that selectors are read-only which don't have a set
method.
Destructuring actions for easier reference
The set
or actions of a state don't rely on this
to work, thus you are free to destructure them for easier reference.
TIP: destructure the actions outside React components so that you don't need to add them into the useCallback/useEffect
dependency array.
import { state, useSnapshot } from "reactish-state";
const countState = state(0, (set) => ({
increase: () => set((count) => count + 1),
reset: () => set(0)
}));
const { increase, reset } = countState.actions;
const Example = () => {
const count = useSnapshot(countState);
return (
<h1>
{count}
<button onClick={() => increase()}>Increase</button>
<button onClick={() => reset()}>Reset</button>
</h1>
);
};
Still perfer Redux-like reducers?
const reducer = (state, { type, by = 1 }) => {
switch (type) {
case "INCREASE":
return state + by;
case "DECREASE":
return state - by;
}
};
const countState = state(0, (set) => ({
dispatch: (action) => set((state) => reducer(state, action), action)
}));
const { dispatch } = countState.actions;
dispatch({ type: "INCREASE", by: 10 });
dispatch({ type: "DECREASE", by: 7 });
console.log(countState.get()); // Print 3
Middleware
You can enhance the functionalities of state with middleware. Instead of using the state
export, you use the createState
export from the library. Middleware is a function which receives set
, get
and subscribe
and should return a new set function.
import { createState } from "reactish-state";
const state = createState({
middleware:
({ set, get }) =>
(...args) => {
set(...args);
// Log state every time after calling `set`
console.log("New state", get());
}
});
// Now the `state` function has wired up a middleware
const countState = state(0, (set) => ({
increase: () => set((count) => count + 1)
}));
countState.set(99); // Print "New state 99"
countState.actions.increase(); // Print "New state 100"
// The same `state` function can be reused,
// thus you don't need to set up the middleware again
const filterState = state("ALL");
filterState.set("COMPLETED"); // Print "New state 'COMPLETED'"
Persist middleware
You can save state in browser storage with the persist
middleware.
import { createState } from "reactish-state";
import { persist } from "reactish-state/middleware";
// Create the persist middleware,
// you can optionally provide a `prefix` prepended to the keys in storage
const persistMiddleware = persist({ prefix: "myApp-" });
const state = createState({ middleware: persistMiddleware });
const countState = state(
0,
(set) => ({
increase: () => set((count) => count + 1)
}),
{ key: "count" } // In the third parameter, give each state a unique key
);
const filterState = state("ALL", null, { key: "filter" });
// Hydrate all the states created with this middleware from storage
useEffect(() => {
// Call `hydrate` in an useEffect to avoid client-side mismatch
// if React components are also server-rendered
persistMiddleware.hydrate();
}, []);
// You can add the `useEffect` once into your root component
By default localStorage
is used to persist states. You can change it to sessionStorage
or other implementations using the getStorage
option.
const persistMiddleware = persist({ getStorage: () => sessionStorage });
Immer middleware
You can mutably update state with the immer
middleware.
import { createState } from "reactish-state";
import { immer } from "reactish-state/middleware/immer";
const state = createState({ middleware: immer });
let todoId = 1;
const todos = state([], (set) => ({
add: (task) =>
set((todos) => {
todos.push({ id: todoId++, task, completed: false });
// Need to return the draft state for correct typing in TypeScript code
// return todos;
}),
toggle: (id) =>
set((todos) => {
const todo = todos.find((todo) => todo.id === id);
if (todo) todo.completed = !todo.completed;
})
}));
// Using the actions
todos.actions.add("Shop groceries");
todos.actions.toggle(1);
Redux devtools middleware
Individual state will be combined into one big object in the Redux devtools for easy inspection.
import { createState } from "reactish-state";
import { reduxDevtools } from "reactish-state/middleware";
const state = createState({ middleware: reduxDevtools({ name: "todoApp" }) });
const todos = state(
[],
(set) => ({
add: (task) =>
set(
(todos) => {
/* Add todo */
},
// Log action type in the second parameter of `set`
"todo/add"
),
toggle: (id) =>
set(
(todos) => {
/* Toggle todo */
},
// You can also log action type along with its payload
{ type: "todo/toggle", id }
)
}),
// Similar to the persist middleware, give each state a unique key
{ key: "todos" }
);
// `todos` and `filter` will be combined into one state in the Redux devtools
const filter = state("ALL", null, { key: "filter" });
Using multiple middleware
Middleware is chain-able. You can use the applyMiddleware
utility to chain multiple middleware and supply the result to createState
.
import { applyMiddleware } from "reactish-state/middleware";
const state = createState({
middleware: applyMiddleware([immer, reduxDevtools(), persist()])
});
Using different middleware in different states
This is naturally achievable thanks to the decentralized state model.
const persistState = createState({ middleware: persist() });
const immerState = createState({ middleware: immer });
const visibilityFilter = persistState("ALL"); // Will be persisted
const todos = immerState([]); // Can be mutated
It also helps eliminate the need for implementing whitelist/blacklist in a persist middleware.
Plugins
While the middleware is used to enhance state, you can hook into selectors using the plugins. The main difference is that plugins don't return a set
function because selectors are read-only. Similarly, you use the createSelector
export from the library rather than selector
.
import { state, createSelector } from "reactish-state";
const selector = createSelector({
plugin: ({ get, subscribe }, config) => {
subscribe(() => {
// Log selector value every time after it has changed
// `config` can hold contextual data from a selector
console.log(`${config?.key} selector:`, get());
});
}
});
const countState = state(0);
const doubleSelector = selector(
countState,
(count) => count * 2,
// Provide contextual data in the last parameter to identity selector
{
key: "double"
}
);
const squareSelector = selector(countState, (count) => count * count, {
key: "square"
});
countState.set(5); // Will log - double selector: 10, square selector: 25
Likewise, there is an applyPlugin
function for applying multiple plugins.
Redux devtools plugin
Individual selector will be combined into one big object in the Redux devtools for easy inspection.
import { createSelector } from "reactish-state";
import { reduxDevtools } from "reactish-state/plugin";
const selector = createSelector({ plugin: reduxDevtools() });
// Then use the `selector` as always...