Choosing Client-Side State Persistence in Reflex: LocalStorage vs In-Memory
A decision guide for Reflex developers choosing between in-memory state and rx.LocalStorage for client-side persistence. Covers constraints, a compact comparison table, trade-offs, a minimal working example, and validation steps.
20 Sept 2026, 06:14 UTC

The Decision: Persist UI State Without a Backend
Reflex applications often need to keep simple UI state—such as a counter, toggle, or form draft—across page reloads without introducing a database or authentication layer. The framework provides two built-in paths: the default in-memory rx.State.var that evaporates on refresh, and rx.LocalStorage which writes to the browser's localStorage API. The choice hinges on three constraints: the data must survive reloads, the added JavaScript payload must stay under ~10 KB, and the developer API should feel like a normal state variable.
Supported Options at a Glance
| Option | Description | Approx. JS Overhead | Reflex Integration |
|---|---|---|---|
rx.LocalStorage |
Built-in wrapper around window.localStorage with automatic JSON serialization |
~2 KB | Drop-in replacement for a typed state var: count: rx.LocalStorage[int] = 0 |
In-memory rx.State.var |
Standard reactive variable stored only in the client's JavaScript heap | 0 KB (default) | Default behavior; no extra import or annotation needed |
| Custom JSON encoder/decoder | Manual JSON.stringify/parse in event handlers, writing to localStorage directly |
Variable (developer-owned) | Requires boilerplate in every handler that mutates the value |
Trade-offs
LocalStorage (rx.LocalStorage)
- Pros: One-line declaration, automatic sync on every mutation, survives full page reloads and browser restarts, negligible bundle impact.
- Cons: Data stored as plain text—anyone with access to the browser can read it; limited to ~5 MB per origin; only JSON-serializable primitives (int, float, str, bool, list, dict) are supported out of the box.
In-Memory State
- Pros: Zero storage footprint, no persistence surface for sensitive data, fastest read/write (pure JS variable access).
- Cons: State resets to initial value on every reload or navigation; unsuitable for anything the user expects to survive a refresh.
Custom Encoder
- Pros: Full control over serialization format, encryption, compression, or schema migration.
- Cons: Easy to introduce sync bugs (forgetting to persist after a mutation), adds boilerplate to every event handler, increases bundle size with custom logic.
Concrete Implementation (Reflex ≥0.4.0)
The following minimal app demonstrates rx.LocalStorage for a counter that survives reloads. Save it as app.py and run with reflex run.
import reflex as rx
class CounterState(rx.State):
count: rx.LocalStorage[int] = 0
def increment(self):
self.count += 1
def decrement(self):
self.count -= 1
@rx.page
def index():
return rx.vstack(
rx.heading(f"Count: {CounterState.count}"),
rx.button("+", on_click=CounterState.increment),
rx.button("-", on_click=CounterState.decrement),
)
if __name__ == "__main__":
rx.app().run()
When the user clicks + or -, Reflex mutates self.count and automatically writes the new integer to localStorage under a key derived from the state class and variable name. On reload, the wrapper reads the stored value before the component renders, so the heading shows the persisted count immediately.
Validation Steps
- Open a terminal in the project directory and run
reflex run(requires Reflex ≥0.4.0; verify withreflex --version). - Open the printed local URL (typically
http://localhost:3000) in a browser. - Click the
+button several times; note the displayed count. - Open DevTools → Application → LocalStorage for the origin. Locate a key prefixed with
CounterState.count(exact key format is an implementation detail). Its value should be a JSON-encoded integer matching the UI. - Reload the page (Ctrl+R or Cmd+R). The heading should show the same count without any additional code.
Limitations and Cautions
- No encryption:
localStorageis readable by any script on the same origin. Do not persist passwords, tokens, PII, or other secrets without applying your own encryption layer before the value reachesrx.LocalStorage. - Serialization boundary: Only JSON-serializable types work natively. For dataclasses, Pydantic models, or custom objects, convert to a
dictorlistfirst (e.g.,model.model_dump()) or implement a custom encoder. - Storage quota: Browsers enforce ~5 MB per origin. Storing large lists or blobs can hit the limit and throw
QuotaExceededError. - Cross-tab sync: Changes in one tab are not automatically reflected in other open tabs. If you need multi-tab sync, listen for the
storageevent onwindowand trigger a state refresh.
Quick Decision Checklist
- Does the value need to survive a full page reload? →
rx.LocalStorage - Is the data sensitive (tokens, passwords)? → In-memory only, or encrypt before persisting.
- Is the value a simple primitive or small JSON structure? →
rx.LocalStorageworks out of the box. - Do you need schema migration, compression, or custom binary format? → Custom encoder (accept the boilerplate cost).
- Is the state purely ephemeral (modal open/closed, transient UI toggle)? → In-memory
rx.State.var.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.