Skip to content
D
Documentation

Subscribe outside components

how-to
2 min readUpdated

Subscribe to store state outside components

Use a vanilla store when code outside React needs to read current state, update it, or react to changes. Bind that same store to React with useStore, and use a selected subscription when an external listener only needs part of the state.

Create a store for non-React code

For createStore, StoreApi, and their getState(), setState(), getInitialState(), and subscribe() methods, see Typed store patterns; this page focuses on using them from non-React code.

The subscription can update a browser element directly, so an external listener changes the page without a React render:

ts
import { createStore } from 'zustand/vanilla'

type CounterStore = {
  count: number
  increment: () => void
}

export const counterStore = createStore<CounterStore>()((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}))

const output = document.createElement('output')
const button = document.createElement('button')
button.type = 'button'
button.textContent = 'Increment'

const render: Parameters<typeof counterStore.subscribe>[0] = (state) => {
  output.textContent = `Count: ${state.count}`
}

render(counterStore.getInitialState(), counterStore.getInitialState())
counterStore.subscribe(render)
button.addEventListener('click', () => counterStore.getState().increment())
document.body.append(output, button)

Read and update the store from any code that imports it. A subscription callback receives the new state and the previous state, and the returned function removes that listener.

ts
import { counterStore } from './counter-store'

const initialCount = counterStore.getInitialState().count
const currentCount = counterStore.getState().count

const unsubscribe = counterStore.subscribe((state, previousState) => {
  console.log('count changed', previousState.count, state.count)
})

counterStore.getState().increment()
counterStore.setState({ count: currentCount + 2 })

console.log('initial count', initialCount)
unsubscribe()

The first read gets the store's initial model; later getState() calls get the current model. The action updates state through the store, and setState() performs the update without requiring a component.

Subscribe to a selected value

Wrap the state creator with subscribeWithSelector when an external listener needs a slice rather than every state update.

ts
import { createStore } from 'zustand/vanilla'
import { subscribeWithSelector } from 'zustand/middleware'

type TemperatureStore = {
  celsius: number
  label: string
  setTemperature: (celsius: number) => void
}

export const temperatureStore = createStore<TemperatureStore>()(
  subscribeWithSelector((set) => ({
    celsius: 20,
    label: 'room',
    setTemperature: (celsius) => set({ celsius }),
  })),
)

const unsubscribe = temperatureStore.subscribe(
  (state) => state.celsius,
  (celsius, previousCelsius) => {
    console.log('temperature changed', previousCelsius, celsius)
  },
  { fireImmediately: true },
)

temperatureStore.setState({ label: 'office' })
temperatureStore.getState().setTemperature(21)
unsubscribe()

The listener fires immediately with the selected value and then fires when celsius changes. The label update does not call this listener because the selected value is unchanged. The selector subscription compares selected values with Object.is unless you provide equalityFn in its options.

Use the same store in React

Pass the vanilla store to useStore and select the value or action the component needs. React subscribes the component to that selection; the component re-renders when the selected value changes.

tsx
import { createRoot } from 'react-dom/client'
import { useStore } from 'zustand'

import { temperatureStore } from './temperature-store'

export function TemperaturePanel() {
  const celsius = useStore(temperatureStore, (state) => state.celsius)
  const setTemperature = useStore(
    temperatureStore,
    (state) => state.setTemperature,
  )

  return (
    <section>
      <p>Temperature: {celsius}°C</p>
      <button type="button" onClick={() => setTemperature(celsius + 1)}>
        Increase
      </button>
    </section>
  )
}

const root = document.createElement('div')
document.body.append(root)
createRoot(root).render(<TemperaturePanel />)

The panel displays the current temperature. Clicking Increase updates the shared vanilla store, and the selected celsius value causes the panel to render the new temperature.

Handle high-frequency changes without re-rendering

For frequently changing state, subscribe in an effect and update a DOM ref directly. The subscription changes the rendered element without making the component render for every pointer event.

tsx
import { useEffect, useRef } from 'react'
import { createStore } from 'zustand/vanilla'

type PointerStore = {
  x: number
  y: number
  setPosition: (x: number, y: number) => void
}

const pointerStore = createStore<PointerStore>()((set) => ({
  x: 0,
  y: 0,
  setPosition: (x, y) => set({ x, y }),
}))

export function PointerPreview() {
  const dotRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const unsubscribe = pointerStore.subscribe((state) => {
      const dot = dotRef.current
      if (dot) {
        dot.style.transform = `translate(${state.x}px, ${state.y}px)`
      }
    })

    return unsubscribe
  }, [])

  return (
    <div
      onPointerMove={(event) =>
        pointerStore.getState().setPosition(event.clientX, event.clientY)
      }
      style={{ position: 'relative', height: 240, width: 400 }}
    >
      <div
        ref={dotRef}
        style={{
          background: 'crimson',
          borderRadius: '50%',
          height: 16,
          position: 'absolute',
          width: 16,
        }}
      />
    </div>
  )
}

The red dot moves with the pointer while the pointer is over the 400-by-240 pixel area. The effect subscribes when the component mounts and removes the listener when it unmounts, so the component does not retain a subscription after teardown.

Options that matter

OptionTypeDefaultWhat it does
equalityFn(a, b) => booleanObject.isDecides whether a selected-value listener runs.
fireImmediatelybooleanfalseCalls a selected-value listener once with its current value when the subscription is created.

Pitfalls

  • Keep the unsubscribe function and call it when the owner of the subscription is disposed. In a React component, return it from the useEffect cleanup as shown above.
  • A selector that creates a new reference on every render can cause update loops in v5. Return a stable reference or follow the shallow-selection guidance in Selectors and rendering.
  • Do not read or write a global store from React Server Components. Create stores per request for server-rendered applications; see Handle server-rendered stores.

Was this page helpful?