juststore
v1.1.2
Published
A small, expressive, and type-safe state management library for React.
Maintainers
Readme
juststore
A small, expressive, and type-safe state management library for React.
Features
- Type-safe deep state with property-style access (
store.user.profile.name) - Path-based API for dynamic access (
store.use("user.profile.name")) - Fine-grained subscriptions powered by
useSyncExternalStore - Optional persistence + cross-tab sync (
createStore) - Memory-only scoped stores (
useMemoryStore,createMemoryStore) - Built-in form state + validation (
useForm,createForm) - Computed, derived, and mixed read models
Installation
bun add juststoreQuick Start
import { createStore } from "juststore";
import { toast } from "sonner";
type AppState = {
user: {
name: string;
preferences: {
theme: "light" | "dark";
};
};
todos: { id: number; text: string; done: boolean }[];
};
const store = createStore<AppState>("app", {
user: {
name: "Guest",
preferences: { theme: "light" },
},
todos: [],
});
async function initUserDetails() {
const response = await fetch("/api/user/details");
const data = (await response.json()) as AppState["user"];
store.user.set(data);
}
function ThemeToggle() {
const theme = store.user.preferences.theme.use();
const nextTheme = theme === "light" ? "dark" : "light";
const updateTheme = async () => {
try {
const response = await fetch("/api/user/preferences/theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: nextTheme }),
});
if (!response.ok) {
throw new Error("Theme update failed");
}
store.user.preferences.theme.set(nextTheme);
} catch {
toast.error("Failed to update theme");
}
};
return <button onClick={updateTheme}>Theme: {theme}</button>;
}Real-World Patterns
1) Debounced search + category filter
type SearchState = {
query: string;
category: "all" | "running" | "stopped";
services: { id: string; name: string; status: "running" | "stopped" }[];
};
const searchStore = createStore<SearchState>("services-search", {
query: "",
category: "all",
services: [],
});
function SearchQueryInput() {
const query = searchStore.query.use() ?? "";
return (
<input
value={query}
onChange={(e) => searchStore.query.set(e.target.value)}
placeholder="Search services"
/>
);
}
function SearchCategoryFilter() {
const category = searchStore.category.use();
return (
<select
value={category}
onChange={(e) =>
searchStore.category.set(e.target.value as SearchState["category"])
}
>
<option value="all">All</option>
<option value="running">Running</option>
<option value="stopped">Stopped</option>
</select>
);
}
function SearchResults() {
const query = searchStore.query.useDebounce(150) ?? "";
const category = searchStore.category.use();
const visible = searchStore.services.useCompute(
(services) => {
const list = services ?? [];
return list.filter((service) => {
const nameMatch = service.name
.toLowerCase()
.includes(query.toLowerCase());
const categoryMatch =
category === "all" ? true : service.status === category;
return nameMatch && categoryMatch;
});
},
[query, category],
);
return (
<ul>
{visible.map((service) => (
<li key={service.id}>{service.name}</li>
))}
</ul>
);
}
function ServiceSearchPage() {
return (
<>
<SearchQueryInput />
<SearchCategoryFilter />
<SearchResults />
</>
);
}2) WebSocket ingestion into normalized state
type RouteUptime = { alias: string; uptime: number };
type UptimeState = {
routeKeys: string[];
uptimeByAlias: Record<string, RouteUptime>;
};
const uptimeStore = createStore<UptimeState>("uptime", {
routeKeys: [],
uptimeByAlias: {},
});
function onUptimeMessage(rows: RouteUptime[]) {
const keys = rows.map((row) => row.alias).toSorted();
uptimeStore.routeKeys.set(keys);
uptimeStore.uptimeByAlias.set(
rows.reduce<Record<string, RouteUptime>>((acc, row) => {
acc[row.alias] = row;
return acc;
}, {}),
);
}
// fine grained subscription
function UptimeComponent({ alias }: { alias: string }) {
const uptime = uptimeStore.uptimeByAlias[alias]?.uptime.use();
return <div>Uptime: {uptime ?? "Unknown"}</div>;
}3) Dynamic object keys for editable maps
type HeaderState = {
headers: Record<string, string>;
};
const headerStore = createStore<HeaderState>("route-headers", {
headers: {},
});
function HeadersEditor() {
// keys is a virtual property that returns a state proxy for the keys array
// it only recomputes when the keys array changes
const keys = headerStore.headers.keys.use();
return (
<div>
{keys.map((key) => (
<div key={key}>
<input
value={key}
onChange={(e) =>
headerStore.headers.rename(key, e.target.value.trim())
}
/>
{/* Render and update without cascade rerendering the entire HeadersEditor */}
<RenderWithUpdate state={headerStore.headers[key]}>
{(value, update) => (
<input value={value} onChange={(e) => update(e.target.value)} />
)}
</RenderWithUpdate>
<button onClick={() => headerStore.headers[key].reset()}>
remove
</button>
</div>
))}
</div>
);
}4) Typed form with validation and submit gating
import { useForm } from "juststore";
import {
StoreFormInputField,
StoreFormPasswordField,
} from "@/components/store/Input"; // from juststore-shadcn
type LoginForm = {
email: string;
password: string;
};
function LoginPage() {
const form = useForm<LoginForm>(
{ email: "", password: "" },
{
email: { validate: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
password: {
validate: (value) =>
value && value.length < 8 ? "Password too short" : undefined,
},
},
);
return (
<form onSubmit={form.handleSubmit((values) => console.log(values))}>
<StoreFormInputField
state={form.email}
type="email"
title="Email"
placeholder="[email protected]"
/>
<StoreFormPasswordField
state={form.password}
title="Password"
placeholder="At least 8 characters"
/>
<button type="submit">Sign in</button>
</form>
);
}5) Mixed read model for unified UI flags
import { createMixedState, createStore } from "juststore";
type OpsState = {
syncingConfig: boolean;
savingRoute: boolean;
reloadingAgent: boolean;
};
const opsStore = createStore<OpsState>("ops", {
syncingConfig: false,
savingRoute: false,
reloadingAgent: false,
});
const busyState = createMixedState(
opsStore.syncingConfig,
opsStore.savingRoute,
opsStore.reloadingAgent,
);
function GlobalBusyOverlay() {
const isBusy = busyState.useCompute(
([syncingConfig, savingRoute, reloadingAgent]) =>
syncingConfig || savingRoute || reloadingAgent,
);
if (!isBusy) return null;
return <div className="overlay">Loading...</div>;
}
function BusyLabel() {
const label = busyState.useCompute(
([syncingConfig, savingRoute, reloadingAgent]) => {
if (syncingConfig) return "Syncing config...";
if (savingRoute) return "Saving route...";
if (reloadingAgent) return "Reloading agent...";
return "Idle";
},
);
return <span>{label}</span>;
}Core Usage
Read and write state
const name = store.user.name.use(); // subscribe
const current = store.user.name.value; // read without subscribe
store.user.name.set("Alice");
store.user.name.set((prev) => prev.toUpperCase());Path-based dynamic API
store.set("user.name", "Alice");
const name = store.use("user.name");
const value = store.value("user.name");Arrays
store.todos.push({ id: Date.now(), text: "new", done: false });
store.todos.at(0).done.set(true);
store.todos.sortedInsert((a, b) => a.id - b.id, {
id: 2,
text: "x",
done: false,
});
const len = store.todos.length;
const liveLen = store.todos.useLength();Computed and derived values
const total = store.cart.items.useCompute(
(items) => items?.reduce((sum, item) => sum + item.price * item.qty, 0) ?? 0,
);
const fahrenheit = store.temperature.derived({
from: (celsius) => ((celsius ?? 0) * 9) / 5 + 32,
to: (f) => ((f - 32) * 5) / 9,
});Render helpers
import { Conditional, Render, RenderWithUpdate } from "juststore";
<Render state={store.counter}>{(value) => <span>{value}</span>}</Render>;
<RenderWithUpdate state={store.counter}>
{(value, update) => (
<button onClick={() => update((value ?? 0) + 1)}>{value}</button>
)}
</RenderWithUpdate>;
<Conditional state={store.user.role} on={(role) => role === "admin"}>
<AdminPage />
</Conditional>;API Reference
Top-Level Exports
createStore(namespace, defaultValue, options?)createMemoryStore(namespace, defaultValue)useMemoryStore(defaultValue)createForm(namespace, defaultValue, fieldConfigs?)useForm(defaultValue, fieldConfigs?)createMixedState(...states)createAtom(id, defaultValue, persistent?)Render,RenderWithUpdate,Conditional,ConditionalRenderisEqual- All public types from
path,types, andform
createStore(namespace, defaultValue, options?)
Creates a persistent store (unless options.memoryOnly is true).
namespace: string- storage namespacedefaultValue: T- default root valueoptions?: { memoryOnly?: boolean }
Returns a store that supports both:
- deep proxy usage (
store.user.name.use()) - path-based usage (
store.use("user.name"))
createMemoryStore(namespace, defaultValue) / useMemoryStore(defaultValue)
Creates memory-only stores (no localStorage persistence).
createMemoryStoreis useful outside React hooks or for explicit namespacesuseMemoryStorecreates component-scoped state keyed byuseId()
createAtom(id, defaultValue, persistent?)
Creates a scalar atom-like state.
persistentdefaults tofalse- methods:
.value,.use(),.set(value | updater),.reset(),.subscribe(listener),.useCompute(fn, deps?)
createForm(namespace, defaultValue, fieldConfigs?) / useForm(defaultValue, fieldConfigs?)
Creates a form store with built-in error state and validation.
Field validators support:
"not-empty"RegExp(value, form) => string | undefined
Additional form methods:
.useError().error.setError(message | undefined).clearErrors().handleSubmit(onSubmit)
createMixedState(...states)
Combines multiple states into one read-only tuple-like state.
.valuereturns current tuple.use()subscribes to all source states.useCompute(fn)computes derived values from the tuple
Render utilities
Render- render-prop helper for read-only usageRenderWithUpdate- render-prop helper with updater callbackConditional- show/hide children based on predicate; usesActivityso children stay mounted when hidden (state preserved)ConditionalRender- render only when predicate is true; children are a render prop receiving the value; returnsnullwhen false (unmounted)
Store / State Methods
Root store methods
| Method | Description |
| -------------------------------- | ----------------------------------------------- |
| .state(path) | Returns a state proxy for the path |
| .use(path) | Subscribes and returns current value |
| .useDebounce(path, delay) | Debounced subscription |
| .useState(path) | [value, setValue] convenience tuple |
| .value(path) | Reads current value without subscription |
| .set(path, value, skipUpdate?) | Sets value (or updater function) |
| .reset(path) | Resets path back to default value for that path |
| .rename(path, oldKey, newKey) | Renames an object key |
| .subscribe(path, listener) | Subscribes to path updates |
| .useCompute(path, fn, deps?) | Computes memoized derived values |
| .notify(path) | Forces listener notification for path |
Common state-node methods
Available on all nodes (store.a.b.c):
| Method | Description |
| ---------------------------- | ------------------------------- |
| .value | Read value without subscribing |
| .field | Last path segment |
| .use() | Subscribe and read |
| .useDebounce(delay) | Debounced subscribe/read |
| .useState() | [value, setValue] |
| .set(value, skipUpdate?) | Set value (or updater function) |
| .reset() | Reset path to default value |
| .subscribe(listener) | Subscribe to path changes |
| .useCompute(fn, deps?) | Compute derived value |
| .derived({ from, to }) | Bidirectional virtual transform |
| .ensureArray() | Array-safe state wrapper |
| .ensureObject() | Object-safe state wrapper |
| .withDefault(defaultValue) | Fallback for nullish values |
| .notify() | Forces listener notification |
Object-state additions
| Method | Description |
| ------------------------- | --------------------------- |
| .keys | Read-only stable keys state |
| .rename(oldKey, newKey) | Rename object key |
| [key] | Nested field access |
Array-state additions
| Method | Description |
| ---------------------------------------- | ------------------------ |
| .length | Current length |
| .useLength() | Subscribe to length only |
| .at(index) / [index] | Access item state |
| .push(...items) | Push items |
| .pop() | Pop item |
| .shift() | Shift item |
| .unshift(...items) | Unshift items |
| .splice(start, deleteCount?, ...items) | Splice items |
| .reverse() | Reverse array |
| .sort(compareFn?) | Sort array |
| .fill(value, start?, end?) | Fill array |
| .copyWithin(target, start, end?) | Copy within array |
| .sortedInsert(cmp, ...items) | Insert by comparator |
Notes
createStorepersists by default; usememoryOnlyfor ephemeral data.resetrestores default path value passed tocreateStore, it does not delete toundefined.
License
AGPL-3.0
