@kitr/state 0.0.0-stage → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Platon Podkopov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @kitr/state
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **State.** Keep app-wide values like a cart or settings, and optionally save them in the browser.
4
+
5
+ ```sh
6
+ pnpm kitr add state
7
+ ```
8
+
9
+ `kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/state`.
10
+
11
+ Built on zustand.
12
+
13
+ Read the guide at https://kitr.dev/packages/state.
14
+
15
+ Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
@@ -0,0 +1,27 @@
1
+ import { StateCreator, StoreApi } from "zustand";
2
+ import { ReactNode } from "react";
3
+ import { PersistOptions } from "zustand/middleware";
4
+ export * from "zustand";
5
+ /** Options for `createKitrStore`: the store's name, whether it is saved in the browser, and devtools. */
6
+ export interface KitrStoreOptions<T> {
7
+ /** Name for devtools and persistence. */
8
+ name: string;
9
+ /** Persist to storage (localStorage by default). Only the client ever reads it. */
10
+ persist?: Partial<PersistOptions<T, Partial<T>>> | boolean;
11
+ /** Connect to the Redux DevTools extension in development. Default `true`; never in production or on the server. */
12
+ devtools?: boolean;
13
+ }
14
+ /**
15
+ * An SSR-safe Zustand store factory. On the client the store is a module-level
16
+ * singleton; on the server (or in tests) you get a fresh store per `<StoreProvider>`,
17
+ * so one user's state never leaks into another's render.
18
+ *
19
+ * const [useCounter, CounterProvider, counterStore] = createKitrStore({ name: 'counter' }, (set) => ({ ... }))
20
+ */
21
+ export declare function createKitrStore<T extends object>(options: KitrStoreOptions<T>, initializer: StateCreator<T, [], []>): readonly [{
22
+ (): T;
23
+ <U>(selector: (state: T) => U): U;
24
+ }, ({ children, initialState }: {
25
+ children: ReactNode;
26
+ initialState?: Partial<T>;
27
+ }) => import("react").JSX.Element, StoreApi<T> | null];
package/dist/index.js ADDED
@@ -0,0 +1,70 @@
1
+ import { createStore } from "zustand";
2
+ import { createContext, useContext, useRef, useSyncExternalStore } from "react";
3
+ import { devtools, persist } from "zustand/middleware";
4
+ import { jsx } from "react/jsx-runtime";
5
+ export * from "zustand";
6
+ // src/create-store.tsx
7
+ /**
8
+ * An SSR-safe Zustand store factory. On the client the store is a module-level
9
+ * singleton; on the server (or in tests) you get a fresh store per `<StoreProvider>`,
10
+ * so one user's state never leaks into another's render.
11
+ *
12
+ * const [useCounter, CounterProvider, counterStore] = createKitrStore({ name: 'counter' }, (set) => ({ ... }))
13
+ */
14
+ function createKitrStore(options, initializer) {
15
+ const build = (initialState) => {
16
+ let creator = initialState ? (set, get, api) => ({
17
+ ...initializer(set, get, api),
18
+ ...initialState
19
+ }) : initializer;
20
+ if (options.persist && typeof window !== "undefined") {
21
+ const persistOptions = typeof options.persist === "object" ? options.persist : {};
22
+ creator = persist(creator, {
23
+ name: `kitr:${options.name}`,
24
+ ...persistOptions
25
+ });
26
+ }
27
+ if (options.devtools !== false && typeof window !== "undefined" && import.meta.env?.DEV) creator = devtools(creator, { name: options.name });
28
+ return createStore(creator);
29
+ };
30
+ const singleton = typeof window !== "undefined" ? build() : null;
31
+ const Context = createContext(null);
32
+ let seeded;
33
+ let serverState;
34
+ const renderedOnServer = (store) => serverState ??= {
35
+ ...store.getInitialState(),
36
+ ...seeded
37
+ };
38
+ function StoreProvider({ children, initialState }) {
39
+ const ref = useRef(null);
40
+ if (!ref.current) {
41
+ ref.current = singleton ?? build(initialState);
42
+ if (singleton && initialState) {
43
+ singleton.setState(initialState);
44
+ seeded = {
45
+ ...seeded,
46
+ ...initialState
47
+ };
48
+ serverState = void 0;
49
+ }
50
+ }
51
+ return /* @__PURE__ */ jsx(Context.Provider, {
52
+ value: ref.current,
53
+ children
54
+ });
55
+ }
56
+ function useKitrStore(selector) {
57
+ const store = useContext(Context) ?? singleton;
58
+ if (!store) throw new Error(`[@kitr/state] Store "${options.name}" has no instance on the server. Wrap the tree in its <StoreProvider>.`);
59
+ const select = selector ?? ((s) => s);
60
+ const snapshot = () => select(store.getState());
61
+ const serverSnapshot = options.persist && store === singleton ? () => select(renderedOnServer(store)) : snapshot;
62
+ return useSyncExternalStore(store.subscribe, snapshot, serverSnapshot);
63
+ }
64
+ return [
65
+ useKitrStore,
66
+ StoreProvider,
67
+ singleton
68
+ ];
69
+ }
70
+ export { createKitrStore };
package/dist/kitr.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ /** `kitr add state`: Zustand with an SSR-safe store factory (per-request stores on the server). */
2
+ declare const _default: {
3
+ readonly name: "state";
4
+ readonly client: true;
5
+ readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
6
+ readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
7
+ readonly generators: {
8
+ readonly store: {
9
+ readonly description: "Create a Zustand store in app/stores";
10
+ readonly definition: "Client-only state; server data belongs in queries, not here.";
11
+ readonly target: (name: string, project: import("@kitr/core/package").ProjectInfo) => {
12
+ file: string;
13
+ vars: Record<string, string>;
14
+ };
15
+ readonly stub: ({ name }: import("@kitr/core/package").GeneratorVars) => string;
16
+ };
17
+ };
18
+ readonly wraps: {
19
+ readonly zustand: "@kitr/state";
20
+ };
21
+ readonly eject: {
22
+ readonly '@kitr/state': "zustand";
23
+ };
24
+ readonly vite: {
25
+ readonly optimizeDeps: ["zustand"];
26
+ };
27
+ };
28
+ export { _default as default };
package/dist/kitr.js ADDED
@@ -0,0 +1,42 @@
1
+ import { definePackage, generatorFile, naming } from "@kitr/core/package";
2
+ // src/kitr.ts
3
+ /** `kitr add state`: Zustand with an SSR-safe store factory (per-request stores on the server). */
4
+ var kitr_default = definePackage({
5
+ name: "state",
6
+ client: true,
7
+ add(ctx) {
8
+ ctx.deps.add("zustand");
9
+ ctx.write("app/stores/.gitkeep", "");
10
+ ctx.agents.add("state", `- Client state only. \`kitr make:store <name>\` writes \`app/stores/<name>.ts\` using \`createKitrStore\`. Server data belongs in queries, not stores.`);
11
+ },
12
+ remove(ctx) {
13
+ ctx.deps.remove("zustand");
14
+ ctx.agents.remove("state");
15
+ },
16
+ generators: { store: {
17
+ description: "Create a Zustand store in app/stores",
18
+ definition: "Client-only state; server data belongs in queries, not here.",
19
+ target: (name, project) => generatorFile(project, "app", "stores", name),
20
+ stub: ({ name }) => `import { createKitrStore } from '@kitr/state'
21
+
22
+ interface ${naming.pascal(name)}State {
23
+ count: number
24
+ increment: () => void
25
+ reset: () => void
26
+ }
27
+
28
+ export const [use${naming.pascal(name)}Store, ${naming.pascal(name)}StoreProvider, ${naming.camel(name)}Store] = createKitrStore<${naming.pascal(name)}State>(
29
+ { name: '${naming.snake(name)}', persist: false },
30
+ (set) => ({
31
+ count: 0,
32
+ increment: () => set((s) => ({ count: s.count + 1 })),
33
+ reset: () => set({ count: 0 }),
34
+ }),
35
+ )
36
+ `
37
+ } },
38
+ wraps: { zustand: "@kitr/state" },
39
+ eject: { "@kitr/state": "zustand" },
40
+ vite: { optimizeDeps: ["zustand"] }
41
+ });
42
+ export { kitr_default as default };
package/docs/api.md ADDED
@@ -0,0 +1,50 @@
1
+ ---
2
+ title: API reference
3
+ description: Every export of @kitr/state.
4
+ order: 2
5
+ ---
6
+
7
+ # API reference
8
+
9
+ `@kitr/state` re-exports everything from `zustand` and adds `createKitrStore`.
10
+
11
+ ## createKitrStore
12
+
13
+ ```ts
14
+ function createKitrStore<T extends object>(
15
+ options: KitrStoreOptions<T>,
16
+ initializer: StateCreator<T>,
17
+ ): readonly [useStore, StoreProvider, store]
18
+ ```
19
+
20
+ Creates a store that is a singleton in the browser and per-provider on the server.
21
+
22
+ ### Options
23
+
24
+ | Option | Type | Default | Description |
25
+ | --- | --- | --- | --- |
26
+ | `name` | `string` | required | Used for devtools and as the storage key `kitr:<name>`. |
27
+ | `persist` | `boolean \| PersistOptions` | `false` | Save to `localStorage`. An object is passed to Zustand's `persist` middleware; its `name` overrides the storage key. |
28
+ | `devtools` | `boolean` | `true` in dev | Connect to Redux DevTools in development. |
29
+
30
+ ### Returns
31
+
32
+ A tuple. Name the parts whatever suits your store.
33
+
34
+ | Position | Value | Description |
35
+ | --- | --- | --- |
36
+ | 0 | `useStore()` / `useStore(selector)` | Hook. Without a selector it returns the whole state. |
37
+ | 1 | `StoreProvider` | Component with `children` and optional `initialState: Partial<T>`. Required on the server. |
38
+ | 2 | `store` | The browser's `StoreApi<T>`, or `null` on the server. |
39
+
40
+ ### Errors
41
+
42
+ Calling the hook on the server without a surrounding `StoreProvider` throws:
43
+
44
+ ```
45
+ [@kitr/state] Store "cart" has no instance on the server. Wrap the tree in its <StoreProvider>.
46
+ ```
47
+
48
+ ## Re-exports from zustand
49
+
50
+ `create`, `createStore`, `useStore`, `StateCreator`, `StoreApi` and everything else Zustand exports. Middleware such as `persist`, `devtools` and `createJSONStorage` comes from `zustand/middleware`.
package/docs/index.md ADDED
@@ -0,0 +1,635 @@
1
+ ---
2
+ title: State
3
+ description: Keep app-wide values like a cart or settings, and optionally save them in the browser.
4
+ order: 1
5
+ ---
6
+
7
+ # State
8
+
9
+ Some values belong to the whole app, not one component: the order Ada is putting together, whether the sidebar is open, Linus's currency. `@kitr/state` is a small store any component can read and update, optionally saved across reloads.
10
+
11
+ > [!SUMMARY]
12
+ > A small store for app-wide values that live in the browser. Add it with `kitr add state`; the overview is at [/packages/state](/packages/state).
13
+
14
+ It is [Zustand](https://zustand.docs.pmnd.rs), re-exported, plus `createKitrStore`, which makes a store safe for server rendering, saves it in the browser when asked, and connects it to the Redux DevTools in development. The examples build Acme's order draft at `app.acme.com`.
15
+
16
+ ## When to use it
17
+
18
+ Use a store for **client state**: values that live in the browser and that no server owns.
19
+
20
+ - A draft order or shopping cart before checkout.
21
+ - UI preferences such as theme, density, currency or a collapsed sidebar.
22
+ - A multi-step wizard's progress.
23
+ - A selection shared by distant components, such as the invoices ticked in a table and the bulk-action bar above it.
24
+
25
+ > [!WARNING]
26
+ > Don't use a store for **server data** such as orders or invoices. Use [Query](/packages/query), which caches and refetches it; a copy in a store goes stale as soon as someone else changes the record.
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ kitr add state
32
+ ```
33
+
34
+ <details>
35
+ <summary>Everything kitr add changes</summary>
36
+
37
+ What `kitr add` does:
38
+
39
+ - Installs `@kitr/state` and `zustand`.
40
+ - Adds `@kitr/state` to `packages` in `kitrrc.ts`. There is no provider to register.
41
+ - Writes an empty `app/stores/.gitkeep`, so the folder for your stores exists.
42
+ - Adds a `## kitr: state` section to `AGENTS.md`: client state only, `kitr make:store <name>` writes `app/stores/<name>.ts` with `createKitrStore`, and server data belongs in queries.
43
+
44
+ </details>
45
+
46
+ `kitr add state` asks no questions. The `app`, `fullstack`, `saas`, `desktop` and `mobile` kits include it. To see the changes without writing them:
47
+
48
+ ```sh
49
+ kitr add state --dry-run
50
+ ```
51
+
52
+ <details>
53
+ <summary>What kitr remove does</summary>
54
+
55
+ `kitr remove state` removes the `packages` entry, both dependencies and the `AGENTS.md` section. It leaves `app/stores/` and your stores in place, and lists the files that still import `@kitr/state`.
56
+
57
+ </details>
58
+
59
+ ## Configure
60
+
61
+ `@kitr/state` has no config file. Each store takes its options as the first argument of `createKitrStore`:
62
+
63
+ ```ts
64
+ // app/stores/preferences.ts
65
+ import { createKitrStore } from '@kitr/state'
66
+
67
+ interface PreferencesState {
68
+ currency: 'USD' | 'EUR'
69
+ setCurrency: (currency: 'USD' | 'EUR') => void
70
+ }
71
+
72
+ export const [usePreferences, PreferencesProvider, preferencesStore] =
73
+ createKitrStore<PreferencesState>(
74
+ {
75
+ name: 'preferences',
76
+ persist: { version: 1 },
77
+ devtools: true,
78
+ },
79
+ (set) => ({
80
+ currency: 'USD',
81
+ setCurrency: (currency) => set({ currency }),
82
+ }),
83
+ )
84
+ ```
85
+
86
+ | Option | Default | Description |
87
+ | --- | --- | --- |
88
+ | `name` | required | Labels the store in the Redux DevTools and builds the storage key `kitr:<name>`. |
89
+ | `persist` | `false` | `true` saves the whole state to `localStorage`; an object takes Zustand's [persist options](https://zustand.docs.pmnd.rs/middlewares/persist). Only the browser touches storage. |
90
+ | `persist.name` | `'kitr:<name>'` | The storage key. |
91
+ | `persist.storage` | `localStorage`, as JSON | Where to save. Use `createJSONStorage(() => sessionStorage)` from `zustand/middleware` for one tab only. |
92
+ | `persist.partialize` | the whole state | A function that picks what to save. |
93
+ | `persist.version` | `0` | The saved shape's version. Saved data with another version goes through `migrate`, or is dropped without one. |
94
+ | `persist.migrate` | none | A function `(saved, version) => state` that upgrades old saved data. |
95
+ | `devtools` | `true` | Connects to the Redux DevTools in development; `false` leaves it out. |
96
+
97
+ The second argument is a Zustand state creator: it gets `set`, `get` and the store, and returns the initial state with its actions. `createKitrStore` returns three things, in this order:
98
+
99
+ | Value | Description |
100
+ | --- | --- |
101
+ | `useStore` | The hook. `useStore(selector)` returns a slice; `useStore()` returns the whole state. |
102
+ | `StoreProvider` | A component that gives its subtree a store. Needed on the server; optional in the browser. Takes `initialState`. |
103
+ | `store` | The browser's store instance, for code outside components. `null` on the server. |
104
+
105
+
106
+
107
+ ## Create your first store
108
+
109
+ Put each store in `app/stores/`. This one holds the order Ada is building:
110
+
111
+ ```ts
112
+ // app/stores/order-draft.ts
113
+ import { createKitrStore } from '@kitr/state'
114
+
115
+ export interface DraftLine {
116
+ sku: string
117
+ name: string
118
+ price: number
119
+ quantity: number
120
+ }
121
+
122
+ interface OrderDraftState {
123
+ customer: string
124
+ lines: DraftLine[]
125
+ setCustomer: (customer: string) => void
126
+ addLine: (line: Omit<DraftLine, 'quantity'>) => void
127
+ removeLine: (sku: string) => void
128
+ clear: () => void
129
+ }
130
+
131
+ export const [useOrderDraft, OrderDraftProvider, orderDraftStore] =
132
+ createKitrStore<OrderDraftState>({ name: 'order-draft', persist: true }, (set) => ({
133
+ customer: '',
134
+ lines: [],
135
+ setCustomer: (customer) => set({ customer }),
136
+ addLine: (line) =>
137
+ set((state) => {
138
+ const existing = state.lines.find((l) => l.sku === line.sku)
139
+ if (!existing) return { lines: [...state.lines, { ...line, quantity: 1 }] }
140
+ return {
141
+ lines: state.lines.map((l) => (l.sku === line.sku ? { ...l, quantity: l.quantity + 1 } : l)),
142
+ }
143
+ }),
144
+ removeLine: (sku) => set((state) => ({ lines: state.lines.filter((l) => l.sku !== sku) })),
145
+ clear: () => set({ customer: '', lines: [] }),
146
+ }))
147
+ ```
148
+
149
+ Then use it from any two components, however far apart:
150
+
151
+ ```ts
152
+ // app/routes.ts
153
+ r.page('/orders/new', { lazy: () => import('./pages/orders/new') })
154
+ ```
155
+
156
+ ```tsx
157
+ // app/pages/orders/new.tsx
158
+ import { useOrderDraft } from '#app/stores/order-draft'
159
+
160
+ function AddLicense() {
161
+ const addLine = useOrderDraft((s) => s.addLine)
162
+ return (
163
+ <button type="button" onClick={() => addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })}>
164
+ Add a seat
165
+ </button>
166
+ )
167
+ }
168
+
169
+ function DraftCount() {
170
+ const count = useOrderDraft((s) => s.lines.reduce((sum, l) => sum + l.quantity, 0))
171
+ return <p>{count} items in this order</p>
172
+ }
173
+
174
+ export default function NewOrderPage() {
175
+ return (
176
+ <main>
177
+ <DraftCount />
178
+ <AddLicense />
179
+ </main>
180
+ )
181
+ }
182
+ ```
183
+
184
+ Open `/orders/new` and press **Add a seat** three times: the count reads `3 items in this order`, and still does after a reload, because `persist: true` saved the draft under `kitr:order-draft`. `kitr make:store` writes a starter store; see [Commands](#commands).
185
+
186
+ ## Read a value in a component
187
+
188
+ Pass a selector to the hook; the component re-renders only when the selected value changes:
189
+
190
+ ```tsx
191
+ // app/components/orders/draft-customer.tsx
192
+ import { useOrderDraft } from '#app/stores/order-draft'
193
+
194
+ export function DraftCustomer() {
195
+ const customer = useOrderDraft((s) => s.customer)
196
+ const setCustomer = useOrderDraft((s) => s.setCustomer)
197
+ return (
198
+ <label>
199
+ Customer
200
+ <input value={customer} onChange={(e) => setCustomer(e.target.value)} />
201
+ </label>
202
+ )
203
+ }
204
+ ```
205
+
206
+ Actions are part of the state, and they never change, so selecting one never causes a re-render.
207
+
208
+ Calling the hook with no selector (`useOrderDraft()`) returns the whole state and re-renders on every change, so keep that for small stores or debugging.
209
+
210
+ ## Select several values at once
211
+
212
+ A selector that builds a new object on every call, such as `(s) => ({ customer: s.customer })`, returns a different value each time and React keeps re-rendering. Call the hook once per value, or wrap the selector in Zustand's `useShallow`:
213
+
214
+ ```tsx
215
+ // app/components/orders/draft-header.tsx
216
+ import { useShallow } from 'zustand/react/shallow'
217
+ import { useOrderDraft } from '#app/stores/order-draft'
218
+
219
+ export function DraftHeader() {
220
+ const { customer, count } = useOrderDraft(
221
+ useShallow((s) => ({ customer: s.customer, count: s.lines.length })),
222
+ )
223
+ return (
224
+ <h2>
225
+ Draft for {customer || 'a new customer'} ({count} lines)
226
+ </h2>
227
+ )
228
+ }
229
+ ```
230
+
231
+ Derived numbers and strings need no `useShallow`.
232
+
233
+ ## Change state with actions
234
+
235
+ `set` merges a partial state into the current one: pass an object, or a function when the new value depends on the old one:
236
+
237
+ ```ts
238
+ // app/stores/invoice-selection.ts
239
+ import { createKitrStore } from '@kitr/state'
240
+
241
+ interface InvoiceSelectionState {
242
+ selected: string[]
243
+ toggle: (invoiceId: string) => void
244
+ selectAll: (invoiceIds: string[]) => void
245
+ clear: () => void
246
+ isSelected: (invoiceId: string) => boolean
247
+ }
248
+
249
+ export const [useInvoiceSelection, InvoiceSelectionProvider, invoiceSelectionStore] =
250
+ createKitrStore<InvoiceSelectionState>({ name: 'invoice-selection' }, (set, get) => ({
251
+ selected: [],
252
+ toggle: (id) =>
253
+ set((s) => ({
254
+ selected: s.selected.includes(id) ? s.selected.filter((x) => x !== id) : [...s.selected, id],
255
+ })),
256
+ selectAll: (ids) => set({ selected: ids }),
257
+ clear: () => set({ selected: [] }),
258
+ isSelected: (id) => get().selected.includes(id),
259
+ }))
260
+ ```
261
+
262
+ `get()` reads the current state inside an action. Keep logic in actions, not components.
263
+
264
+ > [!WARNING]
265
+ > Don't mutate the state in place (`s.selected.push(id)`). `set` compares by reference, so a mutated array looks unchanged and nothing re-renders. Return a new array or object.
266
+
267
+ ## Read and change state outside React
268
+
269
+ The third value is the browser's store instance, for code that is not a component, such as a route loader or a mutation's `onSuccess`:
270
+
271
+ ```ts
272
+ // app/lib/orders/place-order.ts
273
+ import { orderDraftStore } from '#app/stores/order-draft'
274
+
275
+ export async function placeDraftOrder() {
276
+ const draft = orderDraftStore?.getState()
277
+ if (!draft || draft.lines.length === 0) return
278
+ await fetch('/api/orders', {
279
+ method: 'POST',
280
+ headers: { 'content-type': 'application/json' },
281
+ body: JSON.stringify({ customer: draft.customer, lines: draft.lines }),
282
+ })
283
+ orderDraftStore?.getState().clear()
284
+ }
285
+ ```
286
+
287
+ | Method | What it does |
288
+ | --- | --- |
289
+ | `getState()` | Returns the current state, actions included. |
290
+ | `setState(partial)` | Merges `partial` (or the result of a function of the state) into the state. |
291
+ | `setState(state, true)` | Replaces the whole state. Include the actions, or they are gone. |
292
+ | `subscribe(listener)` | Calls `listener(state, previous)` after every change. Returns a function that stops it. |
293
+ | `getInitialState()` | Returns the state the creator first produced. |
294
+
295
+ `subscribe` reacts to changes outside React; this keeps the page title in step with the draft:
296
+
297
+ ```ts
298
+ // app/lib/orders/draft-title.ts
299
+ import { orderDraftStore } from '#app/stores/order-draft'
300
+
301
+ export const stopDraftTitle = orderDraftStore?.subscribe((state, previous) => {
302
+ if (state.lines.length !== previous.lines.length)
303
+ document.title = state.lines.length ? `(${state.lines.length}) Acme` : 'Acme'
304
+ })
305
+ ```
306
+
307
+ The store is `null` on the server, hence the `?.`. In browser-only code such as a click handler, write `orderDraftStore!`.
308
+
309
+ ## Save state in the browser
310
+
311
+ With `persist: true`, the whole state is saved to `localStorage` under `kitr:<name>` after every change and restored on load:
312
+
313
+ ```ts
314
+ // In the browser console after adding one seat
315
+ JSON.parse(localStorage.getItem('kitr:order-draft')!)
316
+ // => { state: { customer: '', lines: [{ sku: 'acme-seat', name: 'Acme seat', price: 12, quantity: 1 }] }, version: 0 }
317
+ ```
318
+
319
+ Actions are not saved; they come from the creator each time. Pass an object to control what is saved. Here `partialize` keeps the lines across a reload but not the customer:
320
+
321
+ ```ts
322
+ // app/stores/order-draft.ts
323
+ import { createKitrStore } from '@kitr/state'
324
+ import type { DraftLine } from './types'
325
+
326
+ interface OrderDraftState {
327
+ customer: string
328
+ lines: DraftLine[]
329
+ clear: () => void
330
+ }
331
+
332
+ export const [useOrderDraft, OrderDraftProvider, orderDraftStore] =
333
+ createKitrStore<OrderDraftState>(
334
+ {
335
+ name: 'order-draft',
336
+ persist: {
337
+ partialize: (state) => ({ lines: state.lines }),
338
+ },
339
+ },
340
+ (set) => ({
341
+ customer: '',
342
+ lines: [],
343
+ clear: () => set({ customer: '', lines: [] }),
344
+ }),
345
+ )
346
+ ```
347
+
348
+ > [!WARNING]
349
+ > `localStorage` is readable by any script on the page and survives sign-out. Never persist tokens, passwords or personal data; use [Secure storage](/packages/secure-storage) for secrets.
350
+
351
+ ## Change the saved shape
352
+
353
+ When the state's shape changes, bump `version` and give `migrate` a function that upgrades old data. Without `migrate`, data from another version is dropped:
354
+
355
+ ```ts
356
+ // app/stores/preferences.ts
357
+ import { createKitrStore } from '@kitr/state'
358
+
359
+ interface PreferencesState {
360
+ currency: 'USD' | 'EUR'
361
+ density: 'comfortable' | 'compact'
362
+ setDensity: (density: 'comfortable' | 'compact') => void
363
+ }
364
+
365
+ export const [usePreferences, PreferencesProvider, preferencesStore] =
366
+ createKitrStore<PreferencesState>(
367
+ {
368
+ name: 'preferences',
369
+ persist: {
370
+ version: 2,
371
+ migrate: (saved, version) => {
372
+ const old = saved as { currency?: 'USD' | 'EUR'; compact?: boolean }
373
+ // Version 1 stored a boolean `compact`; version 2 stores `density`.
374
+ if (version < 2) return { currency: old.currency ?? 'USD', density: old.compact ? 'compact' : 'comfortable' }
375
+ return old as Partial<PreferencesState>
376
+ },
377
+ },
378
+ },
379
+ (set) => ({
380
+ currency: 'USD',
381
+ density: 'comfortable',
382
+ setDensity: (density) => set({ density }),
383
+ }),
384
+ )
385
+ ```
386
+
387
+ ## Save for one tab only
388
+
389
+ `sessionStorage` keeps state per tab and clears it when the tab closes, so Grace can run a wizard in two tabs without them mixing:
390
+
391
+ ```ts
392
+ // app/stores/import-wizard.ts
393
+ import { createKitrStore } from '@kitr/state'
394
+ import { createJSONStorage } from 'zustand/middleware'
395
+
396
+ interface ImportWizardState {
397
+ step: 1 | 2 | 3
398
+ fileName: string | null
399
+ next: () => void
400
+ back: () => void
401
+ }
402
+
403
+ export const [useImportWizard, ImportWizardProvider, importWizardStore] =
404
+ createKitrStore<ImportWizardState>(
405
+ { name: 'import-wizard', persist: { storage: createJSONStorage(() => sessionStorage) } },
406
+ (set) => ({
407
+ step: 1,
408
+ fileName: null,
409
+ next: () => set((s) => ({ step: Math.min(3, s.step + 1) as 1 | 2 | 3 })),
410
+ back: () => set((s) => ({ step: Math.max(1, s.step - 1) as 1 | 2 | 3 })),
411
+ }),
412
+ )
413
+ ```
414
+
415
+ ## Seed a store for a page
416
+
417
+ `StoreProvider` takes `initialState`, merged over the creator's state when the provider mounts. Use it to start a store from loader data, such as the customer of the order Linus is editing:
418
+
419
+ ```ts
420
+ // app/routes.ts
421
+ import { orderQuery } from '#app/queries/orders'
422
+
423
+ r.page('/orders/$orderId/edit', {
424
+ loader: ({ context, params }) => context.queryClient.ensureQueryData(orderQuery.options(params.orderId)),
425
+ lazy: () => import('./pages/orders/orderId/edit'),
426
+ })
427
+ ```
428
+
429
+ ```tsx
430
+ // app/pages/orders/orderId/edit.tsx
431
+ import { OrderDraftProvider } from '#app/stores/order-draft'
432
+ import { DraftEditor } from '#app/components/orders/draft-editor'
433
+ import { getRouteApi } from '@kitr/router'
434
+
435
+ const route = getRouteApi('/orders/$orderId/edit')
436
+
437
+ export default function EditOrderPage() {
438
+ const order = route.useLoaderData()
439
+ return (
440
+ <OrderDraftProvider initialState={{ customer: order.customer }}>
441
+ <DraftEditor />
442
+ </OrderDraftProvider>
443
+ )
444
+ }
445
+ ```
446
+
447
+ <details>
448
+ <summary>How initialState behaves in the browser and on the server</summary>
449
+
450
+ In the browser the store is shared by the whole app, so `initialState` is written into that one store when the provider mounts. It overrides values restored from storage, and components outside the provider see it too. On the server each provider builds its own store with `initialState` applied.
451
+
452
+ </details>
453
+
454
+ ## Render on the server
455
+
456
+ One server process renders pages for many users, so a module-level store would leak Ada's draft into Grace's page. `createKitrStore` avoids that:
457
+
458
+ - **In the browser**, the store is one module-level instance, read without a provider.
459
+ - **On the server**, each `<StoreProvider>` creates a fresh store for its render, and storage is never touched.
460
+
461
+ If a page renders on the server (`ssr` or `static` mode), wrap the part of the tree that reads the store:
462
+
463
+ ```tsx
464
+ // app/document.tsx
465
+ import { HeadContent, Outlet, Scripts } from '@kitr/router'
466
+ import { OrderDraftProvider } from '#app/stores/order-draft'
467
+
468
+ export function RootDocument() {
469
+ return (
470
+ <html lang="en">
471
+ <head>
472
+ <HeadContent />
473
+ </head>
474
+ <body>
475
+ <OrderDraftProvider>
476
+ <Outlet />
477
+ </OrderDraftProvider>
478
+ <Scripts />
479
+ </body>
480
+ </html>
481
+ )
482
+ }
483
+ ```
484
+
485
+ Without a provider, the server render fails with:
486
+
487
+ ```txt
488
+ [@kitr/state] Store "order-draft" has no instance on the server. Wrap the tree in its <StoreProvider>.
489
+ ```
490
+
491
+ In a client-only app (`spa` mode, desktop, mobile) skip the provider.
492
+
493
+ <details>
494
+ <summary>Persisted stores and hydration</summary>
495
+
496
+ The server cannot read browser storage, so a saved store renders its initial state there. The browser's first render uses the same state, so React sees no mismatch, and the saved state follows in a second render.
497
+
498
+ </details>
499
+
500
+ ## Use plain Zustand
501
+
502
+ `@kitr/state` re-exports `zustand` unchanged, so `create`, `createStore` and `useStore` work as in the [Zustand docs](https://zustand.docs.pmnd.rs). A store made with `create` is a singleton on the server too, so use it only in client-only apps or for state that is the same for every user:
503
+
504
+ ```ts
505
+ // app/stores/toasts.ts
506
+ import { create } from '@kitr/state'
507
+
508
+ interface ToastState {
509
+ messages: string[]
510
+ push: (message: string) => void
511
+ }
512
+
513
+ export const useToasts = create<ToastState>()((set) => ({
514
+ messages: [],
515
+ push: (message) => set((s) => ({ messages: [...s.messages, message] })),
516
+ }))
517
+ ```
518
+
519
+ ## Commands
520
+
521
+ | Command | What it does |
522
+ | --- | --- |
523
+ | `kitr make:store <name>` | Writes `app/stores/<name>.ts` with a `createKitrStore` store: a `count`, an `increment` and a `reset` action, not persisted. |
524
+
525
+ ```sh
526
+ kitr make:store order-draft
527
+ ```
528
+
529
+ It writes `app/stores/order-draft.ts`. The file name is kebab-case; the exports are camel and pascal case:
530
+
531
+ ```ts
532
+ // app/stores/order-draft.ts
533
+ import { createKitrStore } from '@kitr/state'
534
+
535
+ interface OrderDraftState {
536
+ count: number
537
+ increment: () => void
538
+ reset: () => void
539
+ }
540
+
541
+ export const [useOrderDraftStore, OrderDraftStoreProvider, orderDraftStore] = createKitrStore<OrderDraftState>(
542
+ { name: 'order_draft', persist: false },
543
+ (set) => ({
544
+ count: 0,
545
+ increment: () => set((s) => ({ count: s.count + 1 })),
546
+ reset: () => set({ count: 0 }),
547
+ }),
548
+ )
549
+ ```
550
+
551
+ Replace the state with your own. `--force` overwrites an existing file; `kitr eject stub store` lets you edit the template in `stubs/store.ts`.
552
+
553
+ ## Testing
554
+
555
+ In a DOM test (Vitest with `happy-dom` or `jsdom`) the store is the browser singleton and keeps its state between tests. Reset it in `beforeEach`, and clear `localStorage` if it persists:
556
+
557
+ ```tsx
558
+ // app/stores/order-draft.test.tsx
559
+ import { act, render, screen } from '@testing-library/react'
560
+ import { beforeEach, describe, expect, it } from 'vitest'
561
+ import { orderDraftStore, useOrderDraft } from './order-draft'
562
+
563
+ function DraftCount() {
564
+ const count = useOrderDraft((s) => s.lines.reduce((sum, l) => sum + l.quantity, 0))
565
+ return <p>{count} items</p>
566
+ }
567
+
568
+ describe('order draft', () => {
569
+ beforeEach(() => {
570
+ localStorage.clear()
571
+ orderDraftStore!.setState({ customer: '', lines: [] })
572
+ })
573
+
574
+ it('adds a line and bumps its quantity', () => {
575
+ const { addLine } = orderDraftStore!.getState()
576
+ addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })
577
+ addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })
578
+ expect(orderDraftStore!.getState().lines).toEqual([
579
+ { sku: 'acme-seat', name: 'Acme seat', price: 12, quantity: 2 },
580
+ ])
581
+ })
582
+
583
+ it('re-renders components that read it', () => {
584
+ render(<DraftCount />)
585
+ expect(screen.getByText('0 items')).toBeTruthy()
586
+ act(() => orderDraftStore!.getState().addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 }))
587
+ expect(screen.getByText('1 items')).toBeTruthy()
588
+ })
589
+
590
+ it('saves the draft under kitr:order-draft', () => {
591
+ act(() => orderDraftStore!.getState().setCustomer('Ada'))
592
+ expect(JSON.parse(localStorage.getItem('kitr:order-draft')!).state.customer).toBe('Ada')
593
+ })
594
+ })
595
+ ```
596
+
597
+ Wrap changes made outside React in `act()` when a component is rendered. To test the server render, use the `node` environment, where there is no singleton, and render through the provider:
598
+
599
+ ```tsx
600
+ // app/stores/order-draft.server.test.tsx
601
+ // @vitest-environment node
602
+ import { renderToString } from 'react-dom/server'
603
+ import { expect, it } from 'vitest'
604
+ import { OrderDraftProvider, orderDraftStore, useOrderDraft } from './order-draft'
605
+
606
+ function Customer() {
607
+ return <p>{useOrderDraft((s) => s.customer) || 'nobody'}</p>
608
+ }
609
+
610
+ it('gives every server render its own store', () => {
611
+ expect(orderDraftStore).toBeNull()
612
+ const ada = renderToString(
613
+ <OrderDraftProvider initialState={{ customer: 'Ada' }}>
614
+ <Customer />
615
+ </OrderDraftProvider>,
616
+ )
617
+ const grace = renderToString(
618
+ <OrderDraftProvider>
619
+ <Customer />
620
+ </OrderDraftProvider>,
621
+ )
622
+ expect(ada).toContain('Ada')
623
+ expect(grace).toContain('nobody')
624
+ })
625
+ ```
626
+
627
+ ## Devtools
628
+
629
+ In development, each `set` shows up in the [Redux DevTools](https://github.com/reduxjs/redux-devtools) extension as an action with the state before and after, under the store's `name`. Production builds and `devtools: false` skip the connection.
630
+
631
+ ## Next steps
632
+
633
+ - [API reference](/packages/state/api): every export and option.
634
+ - [Query](/packages/query): where server data belongs.
635
+ - [Forms](/packages/forms): form state and validation without a store.
@@ -0,0 +1,109 @@
1
+ ---
2
+ title: Overview
3
+ description: Keep app-wide values like a cart or settings, and optionally save them in the browser.
4
+ order: 0
5
+ ---
6
+
7
+ # State
8
+
9
+ ## What it is
10
+
11
+ `@kitr/state` is Zustand with one helper, `createKitrStore`. Define the state and its actions; components select the slice they need and re-render only when it changes. Ask for persistence and the store saves itself, with versions for old shapes.
12
+
13
+ Runs in the browser and during server rendering. For data a server owns, use [query](/packages/query).
14
+
15
+ ## Install
16
+
17
+ ```sh
18
+ kitr add state
19
+ ```
20
+
21
+ Adds `@kitr/state` and zustand, and creates `app/stores/`. No provider, no config file. [Every change](/packages/state/guide#install).
22
+
23
+ ## Quick start
24
+
25
+ Put each store in `app/stores/`. `persist: true` keeps it across reloads:
26
+
27
+ ```ts
28
+ // app/stores/order-draft.ts
29
+ import { createKitrStore } from '@kitr/state'
30
+
31
+ export interface DraftLine {
32
+ sku: string
33
+ name: string
34
+ price: number
35
+ quantity: number
36
+ }
37
+
38
+ interface OrderDraftState {
39
+ lines: DraftLine[]
40
+ addLine: (line: Omit<DraftLine, 'quantity'>) => void
41
+ clear: () => void
42
+ }
43
+
44
+ export const [useOrderDraft, OrderDraftProvider, orderDraftStore] =
45
+ createKitrStore<OrderDraftState>({ name: 'order-draft', persist: true }, (set) => ({
46
+ lines: [],
47
+ addLine: (line) =>
48
+ set((state) => {
49
+ const existing = state.lines.find((l) => l.sku === line.sku)
50
+ if (!existing) return { lines: [...state.lines, { ...line, quantity: 1 }] }
51
+ return {
52
+ lines: state.lines.map((l) => (l.sku === line.sku ? { ...l, quantity: l.quantity + 1 } : l)),
53
+ }
54
+ }),
55
+ clear: () => set({ lines: [] }),
56
+ }))
57
+ ```
58
+
59
+ Then use it from any two components, however far apart. Each selects only what it needs, so it re-renders only when that changes:
60
+
61
+ ```tsx
62
+ // app/pages/orders/new.tsx
63
+ import { useOrderDraft } from '#app/stores/order-draft'
64
+
65
+ function AddSeat() {
66
+ const addLine = useOrderDraft((s) => s.addLine)
67
+ return (
68
+ <button type="button" onClick={() => addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })}>
69
+ Add a seat
70
+ </button>
71
+ )
72
+ }
73
+
74
+ function DraftCount() {
75
+ const count = useOrderDraft((s) => s.lines.reduce((sum, l) => sum + l.quantity, 0))
76
+ return <p>{count} items in this order</p>
77
+ }
78
+
79
+ export default function NewOrderPage() {
80
+ return (
81
+ <main>
82
+ <DraftCount />
83
+ <AddSeat />
84
+ </main>
85
+ )
86
+ }
87
+ ```
88
+
89
+ Press **Add a seat** three times: `3 items in this order`. Reload and it still reads 3, from `localStorage`. `kitr make:store <name>` writes a starter store. Next in the guide: [Create your first store](/packages/state/guide#create-your-first-store).
90
+
91
+ ## Built on
92
+
93
+ kitr adds `createKitrStore`: safe server rendering, persistence with versions, Redux DevTools and seeding from a loader.
94
+
95
+ ## Problems it solves
96
+
97
+ - **Prop drilling app-wide values.** [Read a value in a component](/packages/state/guide#read-a-value-in-a-component).
98
+ - **Re-renders on every change.** [Select several values at once](/packages/state/guide#select-several-values-at-once).
99
+ - **State lost on reload.** [Save state in the browser](/packages/state/guide#save-state-in-the-browser).
100
+ - **Old saved data after a deploy.** [Change the saved shape](/packages/state/guide#change-the-saved-shape).
101
+ - **Server rendering with a global store.** [Render on the server](/packages/state/guide#render-on-the-server).
102
+ - **Debugging state changes.** [Devtools](/packages/state/guide#devtools).
103
+
104
+ ## Further reading
105
+
106
+ - [Guide](/packages/state/guide).
107
+ - [API reference](/packages/state/api).
108
+ - [Zustand docs](https://zustand.docs.pmnd.rs) and [persist options](https://zustand.docs.pmnd.rs/middlewares/persist).
109
+ - [Query](/packages/query) and [Forms](/packages/forms).
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@kitr/state",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.3.0",
4
+ "description": "@kitr/state: Zustand re-exported, plus an SSR-safe store factory.",
5
+ "kitr": {
6
+ "name": "state",
7
+ "title": "State",
8
+ "summary": "Keep app-wide values like a cart or settings, and optionally save them in the browser.",
9
+ "category": "app-state",
10
+ "runsOn": "client",
11
+ "builtOn": [
12
+ "zustand"
13
+ ],
14
+ "client": true,
15
+ "docs": "docs",
16
+ "setup": "./kitr"
17
+ },
18
+ "type": "module",
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/kitrjs/kitr.git",
23
+ "directory": "packages/state"
24
+ },
25
+ "homepage": "https://kitr.dev/packages/state",
26
+ "bugs": "https://github.com/kitrjs/kitr/issues",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "sideEffects": false,
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.ts",
34
+ "default": "./dist/index.js"
35
+ },
36
+ "./kitr": {
37
+ "types": "./dist/kitr.d.ts",
38
+ "default": "./dist/kitr.js"
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "docs"
45
+ ],
46
+ "dependencies": {
47
+ "@kitr/core": "0.3.0"
48
+ },
49
+ "peerDependencies": {
50
+ "react": ">=18.0.0",
51
+ "zustand": ">=5.0.0"
52
+ },
53
+ "devDependencies": {
54
+ "tsdown": "^0.23.7",
55
+ "typescript": "^5.9.3",
56
+ "react": "^19.3.1",
57
+ "@types/react": "^19.3.0",
58
+ "zustand": "^5.1.0"
59
+ },
60
+ "keywords": [
61
+ "kitr",
62
+ "kitr-package"
63
+ ],
64
+ "scripts": {
65
+ "build": "tsdown",
66
+ "typecheck": "tsc -p tsconfig.json"
67
+ }
6
68
  }