@m5nv/deepstate v1.5.1
DeepState (@m5nv/deepstate)
Signal-based Reactive State Management Made Intuitive
Introduction
@m5nv/deepstate is a tiny, reactive state management library built on top of @preact/signals. It transforms plain JavaScript objects into reactive stores, offering an intuitive and refined API that respects the original signals model while improving the developer experience (we think).
Core Concepts
Reactivity via Proxies and Signals
DeepState wraps each property of your state object in a signal using JavaScript proxies. This allows you to work with your state as if it were a plain object, while automatic reactivity is maintained behind the scenes.
Direct vs. Signal Access
Direct State Access:\ Read or update values using
state.prop
for clean, natural code.Signal Access with the Escape Hatch:\ DeepState exposes the underlying signal via an escape hatch when a framework's own reactivity handling may interfere with DeepState's proxy. By default, the underlying signal can be accessed using the
$
prefix (e.g.state.$prop
). In Svelte, since$
is reserved, a dedicated entry point (e.g.@m5nv/deepstate/svelte
) automatically sets the escape hatch to"_"
so you access it asstate._prop
.
Shallow Wrapping
The shallow
helper marks an object so that DeepState wraps only its top-level
properties—leaving nested objects unproxied (non-reactive). This is useful for
optimizing performance or when you intentionally want certain nested data to
remain static. Updating a shallow
property will not affect computed property
under normal operation.
During SSR however, changing shallow
property will affect computed properties
since we use a simple wrapper to emulate signal to avoid running into
integration issues with various frameworks since we cannot control how they do
SSR. This is illustrated in the code below
Default Mode
const staticObj = { id: 1, nested: { value: 42 } };
const { state } = reify(
{ data: shallow(staticObj) },
{ nestedValue: (s) => s.data.nested.value },
false,
);
console.log(state.nestedValue); // 42
// Updating the shallow object does NOT trigger re-computation:
staticObj.nested.value = 100;
console.log(state.nestedValue); // Remains 42 in SPA/Browser mode
SSR Mode:
In SSR mode, computed getters re-run on every access. With the same code, after updating:
console.log(state.nestedValue); // Now becomes 100
In-Place Mutation vs. Immutability
DeepState embraces in-place mutation rather than forcing immutable updates:
- Simplicity: Update state directly (e.g.
state.count++
) without cloning entire objects. - Efficiency: Only affected properties update—avoiding the overhead of deep cloning common in immutable approaches.
- Natural Mapping: You work with state as a plain object while DeepState transparently manages reactivity.
While immutability has its advantages, DeepState’s approach leverages modern JavaScript features (proxies and signals) to deliver an intuitive and performant experience for most UI applications.
Installation
Install via npm:
npm install @m5nv/deepstate
Note: @m5nv/deepstate declares @preact/signals-* as a peer dependency. You are responsible for installing the correct peer dependency to go with the import variant you plan on using.
Import Variants
DeepState supports use with different frameworks in a single installation by utilizing peer dependency and variant import paths. The import paths and the peer dependency is listed below:
For Preact:
import { reify, shallow } from "@m5nv/deepstate";
Peer dependency:
@preact/signals
For React:
import { reify, shallow } from "@m5nv/deepstate/react";
Peer dependency:
@preact/signals-react
For CLI Environments:
import { reify, shallow } from "@m5nv/deepstate/core";
Peer dependency:
@preact/signals-core
For Svelte:
import { reify, shallow } from "@m5nv/deepstate/svelte";
Peer dependency:
@preact/signals-core
\ Note: This special import path automatically configures the escape hatch to use an alternative prefix (default"_"
) to avoid conflicts with Svelte’s reserved$
symbol. This is only needed if you plan on usingattach
api for actions.
Configuration & Environment Modes
DeepState adapts its behavior based on its runtime environment:
SPA Mode:\ In browsers or jsdom environments, DeepState uses native signals. State properties remain as signal objects (with a
.value
property), and shallow objects are non-reactive as intended.SSR Mode:\ In server-side rendering—or when
DEEPSTATE_MODE=SSR
is set—DeepState falls back to a model where:- Computed Properties Re-compute on Every Access:\ Without full dependency tracking in SSR, computed properties re-run on every access. Since SSR rendering happens only once, the performance impact is minimal.
- Automatic Signal Unwrapping:\ Signals return their underlying primitive value to generate valid HTML.
- Impact on Shallow Objects:\ In SPA mode, shallow objects remain non-reactive. In SSR mode, computed getters re-run on every access, so changes to nested values in a shallow object are immediately visible.
DeepState determines its mode using:
// DEEPSTATE_MODE can be "SPA" or "SSR"
// If not set, default to checking window.
const mode = typeof process === "undefined"
? "SPA"
: process.env.DEEPSTATE_MODE;
const isSSR = mode ? mode === "SSR" : (typeof window === "undefined");
Tip: In Node or CLI/test environments, explicitly set
DEEPSTATE_MODE=SPA
if you require SPA behavior.
Usage
DeepState makes state management feel natural by converting your intial state
into signals — deeply. Simply read or write using state.prop
syntax.
When you need access to underlying signal, use state.$prop
(or, for Svelte,
state._prop
) syntax. Note: destructuring DeepState's state props
will result
in losing reactivity; to overcome this you could destructure the native signals
using $
(or _
for Svelte) prefix which remain reactive.
Example: Using DeepState in a Component (React)
For consistency—especially in React—use the escape hatch in your render code:
import { reify } from "@m5nv/deepstate/react";
import { useState } from "react";
function Counter() {
const [{ state: counter, actions }] = useState(() =>
reify(
{ count: 0 },
{ double: (state) => state.count * 2 },
).attach({ on_click: (state) => state.count++ })
);
return (
<div>
<p>{counter.$count} x 2 = {counter.$double}</p>
<button onClick={() => actions.on_click()}>Click me</button>
</div>
);
}
Note: In pure Preact projects, plain properties might work. However, in React
(especially with react-router) always use the $
‑prefixed properties for
reliable updates.
API Reference
reify(initial, computedFns, permissive)
Creates a reactive store from an initial state object.
initial
(object): A non-null object defining your initial state.computedFns
(object): Functions mapping keys to computed (derived) state.permissive
(boolean, default:false
):- Strict mode: Prevents new properties from being added after initialization.
- Permissive mode: Allows dynamic extension of state.
Returns: A store with:
state
: A proxy providing direct access to your state.attach
: A method to bind actions to the store.toJSON
: A method to serialize the state (excluding computed properties).
shallow(obj)
Marks an object as shallow—meaning DeepState wraps only its top-level
properties, leaving nested objects unproxied (non-reactive).\
Usage: Use shallow
when you want to avoid deep wrapping for performance
reasons or when you intend for nested data to remain static.
Attaching Actions
Actions are functions that operate on your state. They can be synchronous or
asynchronous. Bind actions to the store using the attach
method.
Synchronous Action Example
const store = reify({ count: 0 }).attach({
increment(state) {
state.count++;
},
});
store.actions.increment();
console.log(store.state.count); // 1
Asynchronous Action Example
const store = reify({ count: 0 }).attach({
async fetchData(state) {
const response = await fetch("https://jsonplaceholder.typicode.com/todos");
const data = await response.json();
state.count = data.length;
},
});
await store.actions.fetchData();
console.log(store.state.count); // Updated from API response
Best Practices & Considerations
Rendering:\ In React/preact based projects use
$
‑prefixed properties in render code (e.g.<p>{counter.$count} x 2 = {counter.$double}</p>
)Mutations:\ Prefer direct in-place updates (e.g.
state.count++
) for simplicity and efficiency.Batching:\ Utilize
batch()
from@preact/signals
to combine multiple updates into a single re-computation, reducing unnecessary re-renders.State Organization:\ For large or deeply nested state, consider splitting it into multiple stores for better performance and maintainability.
SSR Mode Limitations:\ In SSR mode, computed properties re-run on every access (due to the lack of full dependency tracking), and signals are automatically unwrapped for valid HTML output. This means:
- Computed values always reflect the latest state—even for shallow objects.
- Given that SSR rendering happens only once per request, this trade-off is acceptable for generating static markup.
Integration
React Integration
- Reactivity Considerations:\
In standard Vite React projects using
@preact/signals-react
, you might see that plain state properties update as expected. However, when using frameworks such as react-router or in SSR scenarios, use$
-prefixed property in your render code (e.g.counter.$count
andcounter.$double
). This ensures that React’s reconciliation is triggered reliably.
Svelte Integration
Native Reactivity vs. DeepState:\ Svelte has its own powerful, assignment-based reactivity. For simple read/update scenarios, Svelte’s built-in reactivity may suffice.
When a Store Adapter is Needed:\ In cases where you attach actions that update state via in-place mutations (e.g.
state.count++
), Svelte does not automatically detect the changes. To bridge this gap, a Svelte store adapter is required.Escape Hatch Configuration:\ Because Svelte reserves
$
for its own reactivity, a dedicated Svelte entry point (e.g.@m5nv/deepstate/svelte
) configures the escape hatch to use an alternative prefix (by default,_
). This allows you to access the underlying signal asstate._prop
.
Svelte Store Adapter for Actions
When you attach actions that update state, those in-place mutations won’t trigger Svelte’s reactivity automatically. In such cases, a Svelte store adapter is required to bridge DeepState’s updates with Svelte’s assignment-based reactivity. A simple store adapter is presented below for reference.
<script module>
// deepstateStore.js
import { writable } from "svelte/store";
import { effect } from "@preact/signals-core";
import { reify } from "@m5nv/deepstate/core";
export function createSvelteStore(ds) {
const { state, actions, __version, toJSON } = ds;
const store = writable(state, (set) => {
const stop = effect(() => {
__version.value; // Subscribe to changes via __version.
set(state);
});
return () => stop();
});
return {
subscribe: store.subscribe,
set: store.set,
state,
actions,
toJSON,
};
}
</script>
<script>
// Create a deep state store.
const deepstate = reify(
{ count: 0 },
{ double: (s) => s.count * 2 },
false,
).attach({
on_click: (state) => {
console.log(state.count, state.double);
state.count++;
},
});
const sveltestore = createSvelteStore(deepstate);
</script>
<h2>DeepState Svelte Store</h2>
<input type="number" bind:value="{$sveltestore.count}" />
<button on:click="{sveltestore.actions.on_click}">count</button>
<h3>Is it working?</h3>
<code>{deepstate.state.count} * 2 = {$sveltestore.double}</code>
<pre>{JSON.stringify($sveltestore)}</pre>
<hr />
<ul>
<li>Two-way binding works!</li>
<li>
Fine-grained reactivity is available by accessing the raw <i>state</i>.
</li>
<li>
The Svelte store adapter is needed only when you attach actions to update
state, since in-place mutations are not detected by Svelte's reactivity
automatically.
</li>
</ul>
Contributing
Contributions to improve @m5nv/deepstate are welcome! Please submit issues or pull requests on our GitHub repository.
Credits
DeepState was inspired by deepsignal.
License
Distributed under the MIT License. See LICENSE for details.