rizens

When React Hooks Stop Scaling: Moving Complex State to Zustand

By Oren Farhi on Jun 18, 2026

In most of my React work, I reach for hooks first.

They’re usually the simplest way to keep state close to the UI and encapsulate behavior without introducing additional complexity.

Recently, I was building a speech recognition feature that streamed transcripts in real time, tracked whether the user was speaking, and exposed recognition errors to the UI.

The initial implementation lived inside a custom hook.

const {
  transcript,
  error,
  isUserSpeaking
} = useSpeechRecognition();

At first, that worked well.

Then requirements changed.

I needed transcript updates to be visible in multiple parts of the application. Some updates originated from browser speech recognition events, while others came from services outside the component tree.

That’s when things started getting messy.

The Bug That Made Me Reconsider

The first real warning sign wasn’t architectural.

It was a bug.

Users would occasionally see stale transcript data even though speech recognition events were still arriving.

At first I suspected a browser issue.

Then I suspected event ordering.

Then I spent several hours tracing state updates across effects, refs, callbacks, and component re-renders.

The actual problem was that multiple components were depending on the same state, but ownership of that state was still buried inside a hook that had originally been designed for a single UI flow.

Nothing was technically broken.

The design no longer matched the way the feature was being used.

The Hook Was Doing Too Much

I started with a custom hook because it felt like the simplest way to keep the speech recognition logic near the UI.

That worked until the same state needed to be read outside the original component tree and updated from code that didn’t belong to any one screen.

At that point, the hook wasn’t just reusable logic anymore.

It was acting like shared application state.

The hook eventually became responsible for:

  • Managing speech recognition lifecycle
  • Handling browser-specific events
  • Updating transcripts
  • Tracking speaking state
  • Reporting errors
  • Synchronizing data across multiple consumers

The public API still looked simple.

The implementation no longer was.

Why Context Wasn’t Enough

React Context was an option.

I considered it.

The problem wasn’t simply sharing state.

I also needed updates to originate outside React components.

Speech recognition events arrive asynchronously from browser APIs.

Some consumers only cared about transcript updates.

Others only cared about speaking status.

I wanted state updates to happen independently of React component ownership.

That pushed me toward a dedicated store.

Moving the State to Zustand

Instead of keeping everything inside a hook, I moved the state into Zustand.

const useRecognitionStore = create((set) => ({
  transcript: "",
  isUserSpeaking: false,

  setTranscript: (transcript) =>
    set({ transcript }),

  setSpeaking: (isUserSpeaking) =>
    set({ isUserSpeaking })
}));

The speech recognition service updates the store directly:

recognition.onresult = (event) => {
  useRecognitionStore
    .getState()
    .setTranscript(event.results[0][0].transcript);
};

Components subscribe only to the data they need:

const transcript = useRecognitionStore(
  state => state.transcript
);

That small change removed a surprising amount of complexity.

What Actually Improved

The biggest benefit wasn’t performance.

It was removing ambiguity around state ownership.

Before the migration, I regularly had to answer questions like:

  • Which component owns this state?
  • Why isn’t this update visible here?
  • Which effect updates this value?

After moving state into a store, those questions largely disappeared.

Speech recognition events update the store.

Components read from the store.

The flow became easier to follow because there was a single source of truth instead of state being hidden inside a hook implementation.

When I Still Prefer Hooks

I still use hooks extensively.

If state belongs to a component, useState is usually the right solution.

If I’m encapsulating reusable UI behavior, a custom hook is often exactly what I want.

But once state needs to be shared across unrelated parts of an application and updated from external systems, I start treating it as application state rather than component state.

That distinction has become a useful architectural guideline for me.

Final Thoughts

Nothing was wrong with the original hook.

It solved the problem it was originally designed for.

The problem changed.

The moment state became shared across multiple consumers and started receiving updates from outside React, the hook stopped being the right abstraction.

Moving that state into Zustand didn’t reduce complexity.

The complexity was already there.

It simply made the complexity visible and gave it a place to live.

Check out ReadM and try it out just for fun.

Hi there, I'm Oren Farhi

I'm an Experienced Software Engineer, Front End Tech Lead, focusing on Front End & Software Architecture and creating well formed applications.

Profile Avatar
© 2026, Built by Oren Farhi