DEV Community

Cover image for ๐Ÿš€ Ditch Redux: Build a Global Store in 40 Lines with useSyncExternalStore
Mayank
Mayank

Posted on

๐Ÿš€ Ditch Redux: Build a Global Store in 40 Lines with useSyncExternalStore

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
  })
}
Enter fullscreen mode Exit fullscreen mode

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) or set 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,
}
Enter fullscreen mode Exit fullscreen mode

โš›๏ธ 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>
    )
}

Enter fullscreen mode Exit fullscreen mode

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: '',
})
Enter fullscreen mode Exit fullscreen mode

โšก 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')
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Play with app, Inspect the logger, Reload window and cart persists. Code also includes how to handle async updates.

Top comments (0)