State management is one of the trickiest parts of any React app.
Libraries like Redux, Zustand, and Jotai are popular choices โ but did you know React already ships with everything you need?
In this article, weโll build a shopping cart demo powered entirely by a tiny custom store (~100 lines of code) using the native useSyncExternalStore
hook.
๐ Why useSyncExternalStore
?
React introduced useSyncExternalStore
in v18 to provide a safe way to subscribe to external stores.
It ensures consistent updates, even with concurrent rendering, and removes the need for prop drilling. You can update state from anywhere, even outside React's realm and your UI will react to changes.
๐๏ธ The Store Implementation
Hereโs the heart of our system (syncjs.ts
):
import { useSyncExternalStore } from 'react'
export class Store<T> {
subs = new Set<() => void>()
constructor(private state: T) {
this.subscribe = this.subscribe.bind(this)
this.update = this.update.bind(this)
}
get snapshot() {
return this.state as Readonly<T>
}
subscribe(cb: () => void) {
this.subs.add(cb)
return () => this.subs.delete(cb)
}
update(fn: (state: Readonly<T>) => T) {
this.set(fn(this.snapshot))
}
async update$(fn: (state: Readonly<T>) => Promise<T>) {
this.set(await fn(this.snapshot))
}
set(state: T) {
if (this.state === state) return
this.state = state
this.subs.forEach((cb) => cb())
}
}
export function useStore<T>(store: Store<T>): Readonly<T>
export function useStore<T, R>(
store: Store<T>,
selector: (state: T) => R,
): Readonly<R>
export function useStore<T, R>(store: Store<T>, selector?: (state: T) => R) {
return useSyncExternalStore(store.subscribe, () => {
return selector ? selector(store.snapshot) : store.snapshot
})
}
With just this, you can hold global state (Store<T>
), update it and have strongly typed subscriptions with useStore
No reducers. No boilerplate. Just React.
๐ The Action Pattern
To manipulate store, it is recommended to create store and actions in seperate files.
- create your store with
Store
class - create action functions to update store using it's
update
,update$
(async) orset
methods - Export both the store and actions, so components can subscribe to state and call actions โ without knowing how the store works internally.
This keeps your UI lean, and your state logic centralized.
import { Store } from './syncjs'
type CartItem = {
id: number
price: number
count: number
}
// 1. Create the store
export const $cart = new Store<CartItem[]>([])
// 2. Define actions [ simple demo]
const addItem = (item: Omit<CartItem, 'count'>) => {
if($cart.snapshot.some(p => p.id === item.id) return;
$cart.update(cart => [...cart, { ...item, count: 1 }])
}
const removeItem = (id: number) => {
$cart.update(cart => cart.filter(item => item.id !== id))
}
const clearCart = () => {
$cart.update(() => [])
}
// 3. Export grouped actions
export const cartActions = {
addItem,
removeItem,
clearCart,
}
โ๏ธ Now in your Components, you can just consume the store and call actions on it. Here, useStore($cart, selector)
ensures this button only re-renders when item[id] is added or removed in list, not when unrelated items update. Thatโs the magic of selectors.
function ProductButton({ id, price } : IProduct) {
const inCart = useStore($cart, cart =>
cart.some(item => id === props.id)
)
if (!inCart) {
return (
<button onClick={() => cartActions.addItem({id, price})}>
Add to cart
</button>
)
}
return (
<button onClick={() => cartActions.removeItem(id)}>
Remove from cart
</button>
)
}
If the state of Store depends on async operations (like server data), you can simply use this type for Store
const products = new Store<{
data: IProduct[] | null
loading: boolean
error: string
}>({
data: null,
loading: false,
error: '',
})
โก Superpowers in few extra lines
You can add a localStorage persistor for your store, simply by subscribing to it
export function addPersistor<T>(store: Store<T>, key: string): void {
const state = localStorage.getItem(key)
if (state) store.set(JSON.parse(state))
store.subscribe(() => {
localStorage.setItem(key, JSON.stringify(store.snapshot))
})
//after creating your store somewhere
addPersistor(mystore, 'mystore')
Thatโs it โ no external dependencies, just React.
๐ฏ Takeaways
- You can replace heavy libraries with a tiny, predictable store using React's provided solutions
- Fine-grained subscriptions via selectors keep your app fast.
- You can write your custom addons for store easily [like addLogger]
This demo app shows that managing global state doesnโt have to be complicated.
๐ Try It Yourself
You can look at Product Display App at repo
git clone git@github.com:mynk-tmr/cart-use-sync-external-store.git
cd cart-use-sync-external-store
npm install
node --run dev
Play with app, Inspect the logger, Reload window and cart persists. Code also includes how to handle async updates.
Top comments (0)