Skip to content
D
Documentation

Subscribe outside React

how-to
3 min readUpdated

Use a selected subscription to update a non-React consumer only when its chosen value changes; see State and actions for creating a store with create and reading or updating its state.

This browser example shows the same counter in a React heading and an external consumer's output. Clicking Increment outside React updates both. Clicking Toggle snout changes another field without notifying the counter's selected subscription.

1. Enable selected subscriptions

In your React TypeScript project, install Zustand:

bash
npm install zustand

Wrap the state creator with subscribeWithSelector. This shipped middleware adds subscribe(selector, listener, options) while preserving the whole-store subscription signature.

ts
import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

type CounterState = {
  count: number
  snout: boolean
  toggleSnout: () => void
}

export const useCounterStore = create<CounterState>()(
  subscribeWithSelector((set) => ({
    count: 0,
    snout: true,
    toggleSnout: () => set((state) => ({ snout: !state.snout })),
  })),
)

The result is a React hook with store methods attached. Keep the inferred return type so the middleware's selected subscribe() overload remains available.

2. Read, update and subscribe without calling a hook

getState() returns the current state without subscribing. Use setState() for an external update; the functional form reads the current count, and the default shallow merge preserves the other fields and action.

The listener receives the new and previous selected values. fireImmediately: true also invokes it during setup, with the current value as both arguments. Return the unsubscribe function to whoever owns the consumer's lifetime.

ts
import { useCounterStore } from './store'

export function incrementOutsideReact(): void {
  useCounterStore.setState((state) => ({ count: state.count + 1 }))
}

export function attachCounterOutput(output: HTMLOutputElement): () => void {
  output.textContent = `Count: ${useCounterStore.getState().count}`

  return useCounterStore.subscribe(
    (state) => state.count,
    (count, previousCount) => {
      output.textContent = `Count: ${count} (previous: ${previousCount})`
    },
    { fireImmediately: true },
  )
}

This module runs in the browser and never calls the React hook. The selected listener runs synchronously when the count changes; changing only snout leaves its output untouched.

For all state changes rather than one selected value, pass only a listener to subscribe(). That listener receives the complete current state and previous state, and the call still returns an unsubscribe function.

3. Mount the consumer and clean up on unmount

Give the external consumer its own output element. React selects count and snout through the hook, while the effect connects the external consumer after the element mounts. Returning its unsubscribe function from the effect disconnects that listener on unmount.

tsx
import { useEffect, useRef } from 'react'
import { attachCounterOutput, incrementOutsideReact } from './counter-consumer'
import { useCounterStore } from './store'

export default function App() {
  const count = useCounterStore((state) => state.count)
  const snout = useCounterStore((state) => state.snout)
  const toggleSnout = useCounterStore((state) => state.toggleSnout)
  const outputRef = useRef<HTMLOutputElement>(null)

  useEffect(() => {
    const output = outputRef.current
    if (!output) return
    return attachCounterOutput(output)
  }, [])

  return (
    <section>
      <h1>React count: {count}</h1>
      <p>External consumer: <output ref={outputRef} /></p>
      <p>Snout: {snout ? 'on' : 'off'}</p>
      <button onClick={incrementOutsideReact}>Increment outside React</button>
      <button onClick={toggleSnout}>Toggle snout</button>
    </section>
  )
}

Mount it in your project's entry point, with a <div id="root"></div> in the HTML:

tsx
import { createRoot } from 'react-dom/client'
import App from './App'

const container = document.getElementById('root')!
createRoot(container).render(<App />)

The heading starts at React count: 0, and the external output starts at Count: 0 (previous: 0). Increment once to see the heading change to 1 and the output to Count: 1 (previous: 0). Toggle snout to see its text change while the counter output stays the same.

Subscription options

Pass these options as the third argument of the selected subscribe() overload:

OptionTypeDefaultWhat it does
equalityFn(a: U, b: U) => booleanObject.isCompares selected values. The listener runs only when the comparison returns false. U is the selector's result type.
fireImmediatelybooleanNo immediate callInvokes the listener during setup with the current selected value as both arguments.

Pitfalls

  • Without fireImmediately, subscribing registers a listener but does not render the initial value. Read with getState() and initialize the consumer yourself, or enable the option.
  • Unsubscribe when the consumer tears down, not immediately after subscribing. Unsubscribing removes that listener; it does not remove the store.
  • Middleware that modifies the state creator's set or get does not necessarily modify the public setState() or getState() methods. Use your store's actions when you need their update behavior.
  • For immutable updates and nested merging, see State and actions. For server-side store ownership, see Next.js store setup.

Explore the authors' live Zustand demo for the counter hook in action. Its source shows the React counter; the example above adds an external selected subscription.

Apply the same external subscription and cleanup pattern to a store created with createStore; see How Zustand works for standalone stores and connecting them to React with useStore.

As an alternative entry point, this sample mounts a React counter connected to a vanilla store. Clicking the button updates the store through setState() and the selected count appears in the heading.

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

type CounterState = { count: number }

const counterStore = createStore<CounterState>()(() => ({ count: 0 }))

function incrementOutsideReact(): void {
  counterStore.setState((state) => ({ count: state.count + 1 }))
}

function VanillaCounter() {
  const count = useStore(counterStore, (state) => state.count)
  return (
    <section>
      <h1>React count: {count}</h1>
      <button onClick={incrementOutsideReact}>Increment outside React</button>
    </section>
  )
}

const container = document.getElementById('root')!
createRoot(container).render(<VanillaCounter />)
The React heading displays the vanilla store's initial count of 0 above the external increment button.

Was this page helpful?