Qwik's useStore: Shipping State Without Shipping the JavaScript
Qwik's useStore serializes reactive state into the DOM and defers the component's JavaScript until first interaction. Here's how it works, a counter example, and how to verify the lazy loading yourself.
05 Sept 2025, 03:09 UTC

Open the network tab on a typical SPA and you'll see the problem immediately: hundreds of kilobytes of JavaScript parsed and executed before the user has clicked anything. Most of that code exists to re-create state that the server already rendered into the HTML. Qwik's answer is useStore — a reactive state container whose data is serialized into the DOM at build time and whose behavior is only downloaded and executed when the user actually interacts with it.
The thesis of this post is simple: useStore lets you keep rich, mutable, reactive state without paying for it in your initial bundle. But the mechanism has sharp edges worth understanding before you commit to it.
What useStore actually does
In most frameworks, component state lives in JavaScript memory. On page load, the framework "hydrates" the static HTML by re-running your components to rebuild that state and attach event listeners. That re-run is the expensive part.
Qwik flips this. When you call useStore({ count: 0 }), Qwik wraps the object in a reactive proxy and, during server-side rendering, serializes the store's contents into the HTML itself (as JSON embedded in the page). The component's code — the click handlers, the rendering logic — is split into a separate lazy chunk that the browser never downloads until an event requires it.
The practical consequence: the DOM is fully interactive-looking on first paint, but the JavaScript that powers a given component stays on the server until the user triggers it. Qwik calls this resumability — the app "resumes" where the server left off instead of starting over.
A worked example: the lazy counter
Here's a minimal counter component. You'd write this in any Qwik 1.x project (scaffold one with npm create qwik@latest if you want to follow along):
import { component$, useStore } from '@builder.io/qwik';
export const Counter = component$(() => {
const state = useStore({ count: 0 });
return (
<button onClick$={() => state.count++}>
Count: {state.count}
</button>
);
});Two details matter here. First, the $ suffix on component$ and onClick$ is not decoration — it's a marker for Qwik's optimizer, telling it where it may split code into lazily loaded chunks. The click handler becomes its own downloadable unit. Second, state.count is a proxy property: reads and writes go through Qwik's reactivity layer, which knows exactly which DOM nodes depend on which store properties.
When this page is server-rendered, the button's HTML and the serialized { "count": 0 } ship together. The handler code does not. On the user's first click, Qwik fetches the handler chunk, deserializes the store, and runs the increment. Subsequent interactions are instant because the code is now loaded.
Verifying the lazy behavior yourself
Don't take the framework's word for it — this is easy to check:
- Build for production with
npm run buildand serve the output (e.g.,npm run serve, depending on your starter). Dev mode behaves differently and will mislead you. - Open the page in Chrome with DevTools' Network panel filtered to JS. You should see a small Qwik runtime but no chunk corresponding to your counter handler.
- Click the button. A new JS chunk should appear in the network log at that moment, and the count should increment.
- If the chunk loads on page load instead, the usual culprit is an eager import: something in your entry path imports the component or its dependencies statically, defeating the code split. Check that you haven't imported the handler's logic into a module that runs at startup.
One caveat on observability: stack traces in lazy-hydrated code can be harder to map back to source until hydration has occurred, so keep source maps enabled in your production build while you're learning the model.
The trade-offs
useStore is not free. The proxy indirection means you're not holding a plain object, which occasionally surprises code that expects referential equality or wants to spread the store. Debugging across the serialize/hydrate boundary adds a conceptual step that developers coming from React or Vue will feel in their first week.
More importantly, the benefit is interaction-dependent. A component that every user immediately needs — say, a search box above the fold — gains little from lazy hydration, because its code will be fetched within seconds anyway. The wins are in the long tail: tabs, accordions, modals, secondary dashboards, anything below the fold or behind a click.
Finally, a version note: useStore is stable in Qwik 1.x, but Qwik's APIs and serialization format have evolved across releases. Before adopting it heavily, confirm the API against the version pinned in your package.json and skim the release notes for your minor version — treat anything in this post as 1.x behavior, not a guarantee for future majors.
Where to start
Pick one below-the-fold interactive widget in an existing Qwik app — a counter, a toggle, a small form — and convert its state to useStore. Then run the network-panel verification above. If you see the handler chunk arrive on first interaction rather than on load, you've just removed JavaScript from your critical path without deleting a single feature. That's the whole pitch: state is cheap to ship, code is not, and useStore is how Qwik lets you pay for one without the other.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.