zusteller
Your global state savior. "Just hooks" + zustand.
Disclaimer
Zusteller is brand new, experimental, and under development.
To enable the use of hooks within zustand we render a React element into an HTMLElement in memory and it runs the hook.
When the hook result changes, we update the zustand store.
We need more validation that this approach is performant and doesn't introduce any unexpected or dangerous bugs.
At the very minimum it serves as a proposal for how canonical React global state might be handled.
Motivation:
It is rare that I need global state. Really rare. You can fill 99% of your needs with regular React Hooks and a fetch caching library
(e.g. react-query or swr).
However, when you need to use global state you have to learn a new API. Redux, Zustand, Recoil...the APIs are nice but they
lack one main thing. They are not "just hooks".
A library that only exposes a hook is nice, but if it cannot nest hooks within, if it can't compose hooks in both
directions, then it is what I'm calling a "Terminal Hook". It's the end of the line.
Being a "Terminal Hook" brings challenges. How do you compose or merge various global states together? Redux has
combineReducers
. Recoil has Selectors
. Hooks compose naturally.
Zustand was one of the first libraries to figure out how to elegantly share state without Context. It also has
selectors, a required feature when it's time to optimize performance. Zustand is my go-to global state solution and I consider
it to be a great accomplishment. But it uses a custom API and I really like hooks.
So... what if Zustand could work with regular hooks?
It might look something like this.
"Just hooks" + Zustand
Pass create a hook
First import create
from zusteller
.
import create from 'zusteller'
Pass create
a hook.
const useMyState = () => useState(42)
const useStore = create(useMyState)
Or pass an inline-anonymous hook.
const useStore = create(() => useState(42))
Now components can share the same state. When state updates they will all re-render.
const ComponentA = () => {
const [state, setState] = useStore()
}
const ComponentB = () => {
const [state, setState] = useStore()
}
Perform Logic and Compose Other Hooks
Use as many useState
as you need.
const useStore = create(() => {
const [foo, setFoo] = useState()
const [bar, setBar] = useState()
return
})
Use other custom hooks together.
const useStore = create(() => {
const name = useUserName()
const locale = useLocale()
return { name, locale }
})
Use 3rd party hooks.
import useImmer from 'use-immer'
import usePromise from 'react-use-promise'
const useStore = create(() => {
const [person, updatePerson] = useImmer({
id: 1,
name: "Michael",
age: 33
});
const [products, error] = usePromise(fetch('/api/cart' + person.id))
return {products, person, updatePerson}
})
Note: Contextual hooks will not work, see the section at the bottom.
The returned zustand hook
The hook you are returned is a small wrapper around a regular zustand hook object.
Use it in multiple React components. The state will be shared.
const useStore = create(useState)
const ComponentA = () => {
const [foo, setFoo] = useStore()
}
const ComponentB = () => {
const [foo, setFoo] = useStore()
}
Use zustand's selector functionality normally, reference their docs for more info.
const useStore = create(useState)
const ComponentA = () => {
const foo = useStore(s => s[0])
}
const ComponentB = () => {
const setFoo = useStore(s => s[1])
}
Use it outside of React, using the getState
prototype method.
zustand has a setState
method on the hook, but zusteller does not.
const useStore = create(useState)
const unsub = useStore.subscribe(console.log, s => s[0])
const [foo, setFoo] = useStore.getState()
document.getElementById('button').on('click', () => setFoo('bar'))
unsub()
While zustand can only store object
state, zusteller allows literals, objects, arrays, and undefined/null
.
const useStore = create(() => {
if (false) return { foo: true }
return 'a regular string'
})
const Component = () => {
const msg = useStore(s => s?.foo)
}
Passing Parameters to The Store's Underlying Hook
This is called atomFamily in Recoil and Jotai. It's the ability to create many forked instances of the store based on
parameters passed in during usage.
So if our hook took a parameter, for example an id.
const useUserStore = create(id => {
return useUser(id)
})
You can provide the parameters by passing them in an array as the first argument to the store.
const ComponentA = () => {
const user = useUserStore([42])
}
Each unique combination of parameters gets its own store instance. If two or more components pass the same parameters,
they will share a store.
const ComponentA = () => {
const user = useUserStore([42])
}
const ComponentB = () => {
const user = useUserStore([96])
}
const ComponentC = () => {
const user = useUserStore()
}
const ComponentD = () => {
const user = useUserStore([42])
}
The arguments you pass to the hook are safely memo-ized (just like react-query does). For example this is fine.
const ComponentA = () => {
const user = useUserStore([42, { foo: true, bar: [1, 2, 3] }, 'hello', null])
}
Examples
Migrate Zustand's Doc Examples
You'll have to follow along at https://github.com/pmndrs/zustand/blob/master/README.md
I only recreate the code blocks not all of the text.
import create from 'zusteller'
const useStore = create(() => {
const [bears, setBears] = useState(0)
const increasePopulation = () => setBears(prev => prev + 1)
const removeAllBears = () => setBears(0)
return { bears, increasePopulation, removeAllBears }
})
function BearCounter() {
const bears = useStore(state => state.bears)
return <h1>{bears} around here ...</h1>
}
function Controls() {
const increasePopulation = useStore(state => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
Async actions
I'd just use react-query for this but let's recreate it anyway.
import create from 'zusteller'
const useStore = create(() => {
const [fishies, setFishies] = useState({})
const fetch = async pond => {
const response = await fetch(pond)
setFishies(await response.json())
}
return {fishies, fetch}
})
Oh wait, but now we can compose other hooks! So we can use react-query. Would you look at that?
import create from 'zusteller'
import { useQuery } from 'react-query'
const useStore = create(() => {
const [pond, setPond] = useState('foo')
const { data, ...queryInfo } = useQuery('fishies', () => fetch(`/api/${pond}`))
const fishies = data.map(fish => fish.slippery = true)
return {fishies, queryInfo, setPond}
})
Reading/writing state and reacting to changes outside of components
Works just like zustand.
Except there is no setState
prototype method. You must use methods exposed by
your hook to modify the internal hook's state.
const useStore = create(() => useState({ paw: true, snout: true, fur: true }))
const paw = useStore.getState().paw
const unsub1 = useStore.subscribe(console.log)
const unsub2 = useStore.subscribe(console.log, state => state.paw)
const unsub3 = useStore.subscribe(console.log, state => [state.paw, state.fur], shallow)
const [, setState] = useStore.getState()
setState(prev => ({ ...prev, paw: false }))
unsub1()
unsub2()
unsub3()
useStore.destroy()
Using zusteller without React
Not possible. Use zustand. Zusteller uses hooks, and hooks must be run using react and react-dom.
Want to use immer?
Use a 3rd party immer hook or write your own.
import create from 'zusteller'
import produce from 'immer'
const useImmerState = initialState => {
const [state, setState] = useState(initialState)
const setImmerState = useCallback(setter => setState(produce(setter)), [])
return [state, setImmerState]
}
const useStore = create(() => useImmerState({ lush: { forrest: { contains: { a: "bear" } } } }))
function Component() {
const [state, setState] = useStore()
setState(state => {
state.lush.forrest.contains = null
})
}
Can't live without redux-like reducers and action types?
No judgement I guess. Here's how you do it, you just use useReducer
. Simple.
import create from 'zusteller'
import { useReducer } from 'react'
const types = { increase: "INCREASE", decrease: "DECREASE" }
const reducer = (state, { type, by = 1 }) => {
switch (type) {
case types.increase: return { grumpiness: state.grumpiness + by }
case types.decrease: return { grumpiness: state.grumpiness - by }
}
}
const useStore = create(() => useReducer(reducer, {grumpiness: 0}))
function Component() {
const [state, dispatch] = useStore()
dispatch({ type: types.increase, by: 2 })
}
Migrate Constate's Doc Examples
You'll have to follow along at https://github.com/diegohaz/constate/blob/master/README.md
I only recreate the code blocks not all of the text.
import React, { useState } from "react";
import create from "zusteller";
function useCounter() {
const [count, setCount] = useState(0);
const increment = () => setCount(prevCount => prevCount + 1);
return { count, increment };
}
const useCounterStore = create(useCounter);
function Button() {
const { increment } = useCounterStore();
return <button onClick={increment}>+</button>;
}
function Count() {
const { count } = useCounterStore();
return <span>{count}</span>;
}
function App() {
return (
<>
<Count />
<Button />
</>
);
}
Advanced Example
import React, { useState, useCallback } from "react";
import create from "zusteller";
function useCounter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
const increment = useCallback(() => setCount(prev => prev + 1), []);
return { count, increment };
}
const useCounterStore = create(() => useCounter({ initialCount: 10 }));
function Button() {
const increment = useCounterStore(s => s.increment);
return <button onClick={increment}>+</button>;
}
function Count() {
const count = useCount(s => s.count);
return <span>{count}</span>;
}
function App() {
return (
<>
<Count />
<Button />
</>
);
}
Migrate Recoil's Atoms Tutorial
You'll have to follow along at https://recoiljs.org/docs/basic-tutorial/atoms
I only recreate the code blocks not all of the text.
const useTodoListStore = create(() => {
const [todoList, setTodoList] = useImmerState([])
return { todoList }
})
function TodoList() {
const todoList = useTodoListStore(s => s.todoList);
return (
<>
{}
{}
<TodoItemCreator />
{todoList.map((todoItem) => (
<TodoItem key={todoItem.id} item={todoItem} />
))}
</>
);
}
const useTodoListStore = create(() => {
const [todoList, setTodoList] = useImmerState([])
const todoActions = useMemo(() => ({
add: text => setTodoList(draft => {
draft.push({ id: getId(), text, isComplete: false })
})
}), [])
return { todoList, todoActions }
})
function TodoItemCreator() {
const [inputValue, setInputValue] = useState('');
const todoActions = useTodoListStore(s => s.todoActions);
const addItem = () => {
todoActions.add(inputValue)
setInputValue('');
};
const onChange = ({target: {value}}) => {
setInputValue(value);
};
return (
<div>
<input type="text" value={inputValue} onChange={onChange} />
<button onClick={addItem}>Add</button>
</div>
);
}
let id = 0;
function getId() {
return id++;
}
const useTodoListStore = create(() => {
const [todoList, setTodoList] = useImmerState([])
const todoActions = useMemo(() => ({
add: text => setTodoList(draft => {
draft.push({ id: getId(), text, isComplete: false })
}),
edit: (todo, text) => setTodoList(draft => {
const todo = draft.find(t => t.id === todo.id)
todo.text = text
}),
toggle: todo => setTodoList(draft => {
const todo = draft.find(t => t.id === todo.id)
todo.isComplete = !todo.isComplete
}),
delete: todo => setTodoList(draft => {
return draft.filter(t => t.id !== todo.id)
})
}), [])
return { todoList, todoActions }
})
function TodoItem({item}) {
const todoActions = useTodoListStore(s => s.todoActions)
return (
<div>
<input type="text" value={item.text} onChange={e => todoActions.edit(item, e.target.value)} />
<input
type="checkbox"
checked={item.isComplete}
onChange={() => todoActions.toggle(item)}
/>
<button onClick={() => deleteItem(item)}>X</button>
</div>
);
}
Migrate Recoil's Selectors Tutorial
These code example reference variables created in the previous section
You'll have to follow along at https://recoiljs.org/docs/basic-tutorial/selectors
I only recreate the code blocks not all of the text.
const useTodoListFilterStore = create(() => useState('Show All'));
const useFilteredTodoListStore = create(() => {
const [filter] = useFilteredTodoListStore()
const { todoList } = useTodoListStore()
switch (filter) {
case 'Show Completed':
return todoList.filter((item) => item.isComplete);
case 'Show Uncompleted':
return todoList.filter((item) => !item.isComplete);
default:
return todoList;
}
})
Side Note: In their tutorial they say "The filteredTodoListState internally keeps track of two dependencies:
todoListFilterState and todoListState so that it re-runs if either of those change."
That is what hooks do!
function TodoList() {
const todoList = useFilteredTodoListStore();
return (
<>
<TodoListStats />
<TodoListFilters />
<TodoItemCreator />
{todoList.map((todoItem) => (
<TodoItem item={todoItem} key={todoItem.id} />
))}
</>
);
}
function TodoListFilters() {
const [filter, setFilter] = useTodoListFilterStore();
const updateFilter = ({target: {value}}) => {
setFilter(value);
};
return (
<>
Filter:
<select value={filter} onChange={updateFilter}>
<option value="Show All">All</option>
<option value="Show Completed">Completed</option>
<option value="Show Uncompleted">Uncompleted</option>
</select>
</>
);
}
const useTodoListStatsStore = create(() => {
const { todoList } = useTodoListStore()
const totalNum = todoList.length;
const totalCompletedNum = todoList.filter((item) => item.isComplete).length;
const totalUncompletedNum = totalNum - totalCompletedNum;
const percentCompleted = totalNum === 0 ? 0 : totalCompletedNum / totalNum;
return {
totalNum,
totalCompletedNum,
totalUncompletedNum,
percentCompleted,
};
})
function TodoListStats() {
const {
totalNum,
totalCompletedNum,
totalUncompletedNum,
percentCompleted,
} = useTodoListStatsStore();
const formattedPercentCompleted = Math.round(percentCompleted * 100);
return (
<ul>
<li>Total items: {totalNum}</li>
<li>Items completed: {totalCompletedNum}</li>
<li>Items not completed: {totalUncompletedNum}</li>
<li>Percent completed: {formattedPercentCompleted}</li>
</ul>
);
}
Migrate Recoil's Asynchronous Data Queries Guide
You'll have to follow along at https://recoiljs.org/docs/guides/asynchronous-data-queries
I only recreate the code blocks not all of the text.
Synchronous Example
const useCurrentUserIDStore = create(() => useState(1))
const useCurrentUserNameStore = create(() => {
const [id] = useCurrentUserIDStore()
return tableOfUsers[id].name;
});
function CurrentUserInfo() {
const userName = useCurrentUserNameStore();
return <div>{userName}</div>;
}
function MyApp() {
return (
<CurrentUserInfo />
);
}
Asynchronous Example
Hey let's use react-query my current favorite library! I mean really this example doesn't
even need global state at all... react-query is all you need here. But I'll show it anyway.
import { useQuery } from 'react-query'
const useCurrentUserNameStore = create(() => {
const [id] = useCurrentUserIDStore()
const { data } = useQuery(['user/details', id], (_, id) => myDBQuery({ userID: id }))
return data?.name;
})
function CurrentUserInfo() {
const userName = useCurrentUserNameStore();
return <div>{userName}</div>;
}
Hmm ok so this one... I mean Suspense isn't really supported. It's not NOT supported either.
Like if you set the suspense: true
option in react-query it will work. But I have a hot take,
maybe suspense is not so great. I prefer managing the loading state inside the component that
is actually loading!! This way I can show a custom tailored skeleton, or continue showing
stale data when it's refetching.
function MyApp() {
return (
<RecoilRoot>
<React.Suspense fallback={<div>Loading...</div>}>
<CurrentUserInfo />
</React.Suspense>
</RecoilRoot>
);
}
I'm not gonna talk about ErrorBoundaries, whatever.
Queries with Parameters
Ok now this one is interesting let's see... I'd just react-query for this without zusteller.
function UserInfo(id) {
const { data } = useQuery(['user/details', id], (_, id) => myDBQuery({ userID: id }))
return <div>{data?.name}</div>;
}
But that's cheating... so what if we needed to pass parameters to our hook? Hmm...
ok let's just pretend react-query wasn't invented yet.
const useUserNameStore = create((userID) => {
const [name, setName] = useState('')
const [error, setError] = useState()
useEffect(() => {
myDBQuery({userID}).then(response => {
setResponse(response.error ?? response.name)
});
}, [id])
if (error) return error
return name
})
function UserInfo({ id }) {
const { data } = useUserNameStore([id])
return <div>{data?.name}</div>;
}
Or we could use a 3rd party library hook like react-use-promise. It's a little nicer,
but again react-query would just be better here. But this illustrates passing in parameters.
import usePromise from 'react-use-promise'
const useUserNameStore = create((userID) => {
const [result, error] = usePromise(myDBQuery({userID}))
return error ?? result.name
})
function UserInfo(id) {
const { data } = useUserNameStore([id])
return <div>{data?.name}</div>;
}
What about `React.Context`?
This only partially works. It works ok with global themes or other providers that wrap your whole app.
But it's can behave badly if you have components sharing the same store but living under different contexts.
const SomeContext = React.createContext()
const useContextStore = create(() => useContext(SomeContext))
const Component = () => {
const value = useContextStore()
return <div>{value}</div>
}
const App = () => {
return (
<>
<SomeContext.Provider value={true}>
<Component/>
</SomeContext.Provider>
<SomeContext.Provider value={false}>
<Component/>
</SomeContext.Provider>
</>
)
}
That's because the underlying hook is being run only by the first component that uses the store hook.
So context will be relative to that first subscribing component. So in the example above, both components
would like use the value of true
. So yeah...
One way we could fix this in the future is by returning a React element from create
.
You could then place this element anywhere as your Context.Consumer location.
const SomeContext = React.createContext()
const useStore = create.withManualInsert(() => {
return useContext(SomeContext)
})
const App = () => {
return (
<SomeContext.Provider>
<useStore.ContextConsumerPoint /> // Now all useStore usages will pick up the context from an intentional place
</SomeContext.Provider>
)
}
Maybe we also have a way to return it's own context. For a constate
flavor.
This would lower the state from global to contextual.
const useStore = create.withContext(MyContext => () => {
return useContext(MyContext)
})
const App = () => {
return (
<useStore.Provider>
<useStore.Store />
</useStore.Provider>
)
}