This is the abridged developer documentation for @lolmaus/config-store
# @lolmaus/Config-store
Manage frontend settings transparently!
A frontend library to store an arbitrary config (user settings) with strict typing, Zod validation, automated schema migrations, adapters to persist to localStorage or backend
## Features
- **Universal Config**
Store user settings feature flags or any persistent client-state in a centralized JSON-like
structure.
- **Zod-Powered**
The config is defined as a [Zod](https://zod.dev) schema, providing strict TypeScript inference
across your codebase. Each setting can be anything from a `boolean` to a complex nested object.
The Zod schema also provides defaults for each value.
- **Migrations**
As your schema changes, define migration functions to automatically update a user's config to
conform to the new schema. This process is transparent to the consuming app.{' '}
- **Flexible Adapters**
Ships with a simple `LocalStorageAdapter` and a versatile `AsyncAdapter` (e. g. for REST APIs).
You can easily define custom adapters for other protocols (e.g., WebSocket, IndexedDB).
- **Concurrency Control**
The `AsyncAdapter` provides three strategies for parallel writes:
- **abort**: Cancels previous pending requests (default, relies on AbortController).
- **optimistic concurrency control**: On top of the **abort** strategy, your backend can emit
`409 Conflict` on an attempt to overwrite an newer config version with a newer one.
- **sequential**: Waits for each update to finish before processing the next one. Updates pile
up in a queue. Useful for legacy backends, at the cost of UX.
- **Framework-agnostic**
The core can be used with vanilla JS or in any framework.
The following framework integrations are available:
- **React**: Provides hooks like `useConfig` and `useUpdateConfig`. Makes sure your components rerender only when the relevant individual settings change. Unrelated changes to the config will not cause rerenders.
Support for other frameworks is not planned, but contributions are very welcome.
# Installation
## Core package
Make sure you have [Zod 4](https://zod.dev) installed.
Install the `@config-store/core` package using your preferred npm-based package manager:
```sh
npm add @config-store/core
pnpm add @config-store/core
yarn add @config-store/core
bun add @config-store/core
```
⠀
## In a React app
Additionally, install `@config-store/react`.
Make sure you're on React 18+.
⠀
## Use with other frameworks
Support for other frameworks is not planned, but contributions are very welcome.
Meanwhile, you can integrate the `ConfigManger` by hand.
⠀
## Version compatibility
If using previous versions of packages, mind version compatibility table:
| Branch | @config-store/core | @config-store/react |
| ---------------- | ------------------ | ------------------- |
| `gen0` (current) | >= 1.0.0 | >= 1.0.0 |
# Schema Defintion Rules
`@config-store` relies on [Zod](https://zod.dev) schema to provide default values.
This means that the schema must be able to accept an empty initial value (e. g. `null` or `undefined`) and parse it into a default config.
Here's how to achieve that, assuming you store settings in an object.
## Handling undefined initial value
If your adapter receives `undefined` as an empty initial value, then you must attach [.prefault({})](https://zod.dev/api?id=prefaults) to your outmost `z.object()`.
```ts ".prefault({})"
const MySettingsSchema = z.object().prefault({}); // Converts initial `undefined` value to `{}`
MySettingsSchema.parse(undefined); // => {}
```
## Handling null initial value
If your adapter receives `null` as an empty initial value, then you must wrap the entire schema with [.preprocess()](https://zod.dev/api#preprocess), converting `null` into an empty object `{}`.
Also handles `undefined`!
```ts "z.preprocess(" "(config: unknown) => config ?? {}," "null"
const MySettingsSchema = z.preprocess(
(config: unknown) => config ?? {},
z.object() // You don't need `.prefault({})` on root level when using `.preprocess()`
);
MySettingsSchema.parse(null); // => {}
```
## Handling default values for properties
You must attach [.default()](https://zod.dev/api?id=defaults) to every primitive property.
```ts /(.default.+),/ "{}"
const MySettingsSchema = z.object({
menuExpanded: z.boolean().default(true),
darkTheme: z.boolean().default(false),
});
MySettingsSchema.parse({}); // => {menuExpanded: true, darkTheme: false}
```
## Handling nested objects
You must attach [.prefault({})](https://zod.dev/api?id=prefaults) to every nested object.
```ts ".prefault({})" "{}"
const MySettingsSchema =
z.object({
nestedSettings: z.object().prefault({}),
})
);
MySettingsSchema.parse({}); // => {nestedSettings: {foo: 'bar', baz: 'zomg'}}
```
## Handling everything together
Now you're ready to build your schema:
```ts /(.default.+),/ ".prefault({})" "undefined"
const MySettingsSchema = z
.object({
menuExpanded: z.boolean().default(true),
darkTheme: z.boolean().default(false),
nestedSettings: z
.object({
foo: z.string().default('bar'),
baz: z.literal(['quux', 'zomg', 'lol']).default('zomg'),
})
.prefault({}),
})
.prefault({});
MySettingsSchema.parse(undefined); // =>
/**
* {
* menuExpanded: true,
* darkTheme: false,
* nestedSettings: {
* foo: 'bar',
* baz: 'zomg'
* }
* }
*/
```
# React Quickstart
## 1. Define the manager and the schema
In e. g. `src/settings/manager.ts`, instantiate the adapter and pass it to the `ConfigManager`.
Chain `.addVersion()` to define your schema history.
```ts
import {ConfigManager, LocalStorageAdapter, type InferConfig} from '@config-store/core';
import {z} from 'zod';
// Create your adapter (or import a custom one)
const adapter = new LocalStorageAdapter({key: 'my-app-settings'});
// Initialize the manager with the adapter
export const configManager = ConfigManager
// Initialize with adapter and schema version 1
.create(adapter, {
version: 1,
schema: z
.object({
menuExpanded: z.boolean().default(true),
darkTheme: z.boolean().default(false),
})
.prefault({}),
})
// Eventually, add schema version 2 as your settings evolve
.addVersion({
version: 2,
schema: z
.object({
menuExpanded: z.boolean().default(true),
// Changed from boolean 'darkTheme' to 'theme' typed as 'light' | 'dark' | 'high-contrast'
theme: z.literal(['light', 'dark', 'high-contrast']).default('light'),
})
.prefault({}),
// Migrate uesrs from schema version 1 to 2
migration: (prev) => {
// TypeScript automatically infers 'prev' as the previous version 😙👌
return {
...prev,
// Migrate the theme setting from boolean to string
theme: prev.darkTheme ? ('dark' as const) : ('light' as const),
};
},
});
// Export the current config type
export type MySettings = InferConfig;
```
⠀
## 2. Generate typed hooks
In e. g. `src/settings/hooks.ts`, make versions of hooks `useConfig` and `useUpdateConfig` that are typed with your current config shape:
```ts
import {createHooks} from '@config-store/react';
import type {MySettings} from './manager';
export const {useConfig, useUpdateConfig, useUpdateConfigReducer} = createHooks();
```
⠀
## 3. Wrap your app with the config provider, load the config
Pass your `configManager` instance into the `manager` prop of the provider.
```tsx
import {ConfigProvider} from '@config-store/react';
import {configManager} from './settings/manager';
export const AuthenicatedPageLayout = () => {
// Fetch settings when the app is initially loaded
configManager.load();
return (
);
};
```
⠀
## 4. Read config
Use the hook `useConfig` to read config:
```tsx
import {useConfig} from 'my-app/settings/hooks';
export const PageWrapper = ({children}) => {
// Get the entire settings object
const config = useConfig();
// Pass a selector to subscribe only to specific changes (renders optimized)
const theme = useConfig((s) => s.theme);
return
{children}
;
};
```
⠀
## 5. Persist config updates
`@config-store/react` provides _three_ ways of updating the store. Pick the one that suits your case.
⚠️ If you're not using [React Compiler](https://react.dev/learn/react-compiler), make sure to tighten these examples with `useCallback` and `useMemo`.
⠀
### 5.1. Write the entire config into the store
```tsx "clickHandler" {10-14} "config"
import {useUpdateConfig} from 'my-app/settings/hooks';
export const ThemeToggler = () => {
const config = useConfig();
const {update} = useUpdateConfig();
// This is the value we're gonna write to the store
const [userInput] = useState<'light' | 'dark'>(config.theme);
const clickHandler = () =>
update({
...config,
theme: userInput,
});
return (
);
};
```
⠀
### 5.2. Update a specific value in the store with a mutator
```tsx /{(clickHandler)}/ {12-18} "config"
import {useUpdateConfig} from 'my-app/settings/hooks';
export const ThemeToggler = () => {
// Selector selects a specific property on the settings
const theme = useConfig((c) => c.theme);
// This is the value we're gonna write to the store
const [userInput] = useState<'light' | 'dark'>(theme);
const {update} = useUpdateConfig();
const clickHandler = () => {
// Pass a mutator callback into the `update`
update((config) => ({
...config,
theme: userInput,
}));
};
return (
);
};
```
⠀
### 5.3. Customize the update function with a reducer
`useUpdateConfigReducer` lets you preconfigure the `update` function to receive a narrow value.
In this example, we're setting it up to receive the theme value typed as `'light' | 'dark'`.
This lets us pass the `update` function directly into Select's `onChange`, without having to wrap `update` with a handle callback like in the previous example.
```tsx /(update)}/ {10-14} /(config),/
import {useUpdateConfigReducer} from 'my-app/settings/hooks';
export const ThemeToggler = () => {
// Selector selects a specific property on the config
const theme = useConfig((c) => c.theme);
// This is the value we're gonna write to the store
const [userInput] = useState<'light' | 'dark'>(config.theme);
// Preconfigure the update function to receive a theme value
const {update} = useUpdateConfigReducer<'light' | 'dark'>((config, theme) => ({
...config,
theme,
}));
return (
);
};
```
# LocalStorageAdapter
The local storage adapter persists the settings in localStorage. It's synchronous and instant.
You can instantiate it and pass directly to the manager:
```ts {3} /const (adapter)/ /(adapter),/
import {ConfigManager, LocalStorageAdapter} from '@config-store/core';
const adapter = new LocalStorageAdapter();
export const configManager = ConfigManager.create(adapter, {
version: 1,
schema: z.object().prefault({}),
});
```
## Customizing localStorage key
By default, the settings are saved to the `@lolmaus/config-store` localStorage entry.
To change the key, pass the `key` option:
```ts "{key: 'my-settings'}"
new LocalStorageAdapter({key: 'my-settings'});
```
# AsyncAdapter
`AsyncAdapter` is a flexible way of persisting data over network, e. g. to a REST API.
⠀
## 1. Defining the adapter
Define the adapter in a separate file `src/settings/adapter.ts` using the `create` static method:
```ts "AsyncAdapter.create"
import {AsyncAdapter, AdapterEnvelopeSchema} from '@config-store/core';
export const apiAdapter = AsyncAdapter.create({
read: async () => {
const res = await fetch('/api/settings');
if (!res.ok) throw new Error('Failed to fetch');
const json = await res.json();
// AdapterEnvelopeSchema is a Zod schema that parses the payload as `{config, metadata}`.
// Assuming server returns `{data: {config, metadata}}`
return AdapterEnvelopeSchema.parse(json?.data);
},
write: async (config, _lastCommittedConfig, metadata, signal) => {
const payload = {
config,
metadata, // e.g. {dataVersion: 1, schemaVersion: 1}
};
const res = await fetch('/api/settings', {
method: 'PUT',
body: JSON.stringify(payload),
headers: {'Content-Type': 'application/json'},
signal,
});
if (!res.ok) throw new Error('Save Failed');
// If your backend does not respond to writes with a payload, you can skip this.
const json = await res.json();
// AdapterEnvelopeSchema is a Zod schema that parses the payload as `{config, metadata}`.
// Assuming server returns `{data: {config, metadata}}`
return AdapterEnvelopeSchema.parse(json?.data);
},
});
```
Then register your adapter with the ConfigManager:
```ts
import {apiAdapter} from './adapter';
export const configManager = ConfigManager.create(adapter, {
/* Inital verison here */
});
```
⠀
## 2. Handling Concurrency (Race Conditions)
When a user modifies settings rapidly (e.g., dragging a volume slider), multiple save requests are generated. Network latency can cause these requests to arrive out of order.
The `AsyncAdapter` supports three strategies via the `concurrency` option to solve this:
⠀
### 2.1. Abort strategy — default
**Best for:** Modern backends and standard APIs.
When a new save starts, the library automatically aborts the previous pending request using the browser's `AbortController`.
- **Pros:** Prevents race conditions; reduces server load; UI feels snappy.
- **Cons:** Backend/Fetch must support `AbortSignal` (Standard `fetch` does).
```ts
new AsyncAdapter({
concurrency: 'abort', // default
write: async (config, _lastCommittedConfig, metadata, signal) => {
// Pass the signal to fetch!
await fetch('/api/settings', {
method: 'POST',
body: JSON.stringify({config, metadata}),
signal,
});
},
});
```
⠀
### 2.2. Optimistic Concurrency Control — best solution, requires backend logic
**Best for:** when you can customize backend logic.
Use the `abort` strategy for this approach, no frontend changes are necessary. The main difference happens on the backend side.
Why: HTTP requests may be processed in a different order from the order they have been initiated in. As a result, latest data may be overwritten by obsolete data. To prevent this, [Optimistic Concurrency Control](https://en.wikipedia.org/wiki/Optimistic_concurrency_control) (OCC) should be employed.
With each request, your adapter should send metadata containing a `dataVersion` number. `@config-store` automatically provides the metadata and increments the `dataVesion` on each save.
If requests come in the wrong order, the backend may process a recent request first. When it then processes an older request, the backend must check if the `dataVersion` of the request being processed is larger than `dataVersion` in the database. If it's not, the backend rejects the request with `409 Conflict` HTTP code.
The `AsyncAdapter` will ignore `409 Conflict` responses and drop corresponding save operations.
Ths strategy guarantees that older data does not overwrite newer data.
⠀
### 2.3. Sequential strategy — legacy Fallback
**Best for:** Legacy backends that do not support HTTP request cancellation and do not handle versioning.
The library waits for Request A to finish before sending Request B.
- **Pros:** Safe; works with anything.
- **Cons:** Slow. If the network is laggy, the "Save" indicator may spin for a long time.
```ts
new AsyncAdapter({
concurrency: 'sequential',
write: async (config): Promise => {
// This will not run in parallel with another write
await fetch('/api/settings', {
/*...*/
});
},
});
```
⠀
## 3. Handling Backend Responses on save
Sometimes, the server modifies the data you sent, e. g. sanitizing it.
The return type of your `write` callback is `AdapterEnvelope | void`, where `AdapterEnvelope` is:
```ts
{
config: unknown;
metadata: ManagerMetadata;
}
```
Return `void`: The library keeps the "Optimistic Update" (the value the user set).
Return `AdapterEnvelope`: The library silently updates the store with the data returned from the server.
```ts
const apiAdapter = new AsyncAdapter({
write: async (config, _lastCommittedConfig, metadata, signal) => {
const res = await fetch('/api/settings', {
/*...*/
});
const json = await res.json();
// AdapterEnvelopeSchema is a Zod schema that parses the payload as `{config, metadata}`.
// Assuming server returns `{data: {config, metadata}}`
return AdapterEnvelopeSchema.parse(json?.data);
},
});
```
# Loading and Error States
WIP
# Loading and Error States
When using the `AsyncAdapter`, you want to handle loading and error states.
The library provides granular status flags to handle loading screens, error boundaries, and notifications.
⠀
## Loading state
### Using defaults while settings are loading
One of the core features of `@config-store` is strict Zod validation with default values.
When you initialize the `ConfigManager`, the store is **immediately** populated with the default values defined in your Zod schema. This happens _before_ `adapter.read()` returns data.
This means you often **do not need a loading state**. Your UI will render immediately with valid default settings, and then "hydrate" with user preferences once the adapter loads.
```tsx
// Even if load() is pending, this returns `true` (if that is the default in schema)
const isMenuExpanded = useConfig((c) => c.menuExpanded);
return ;
```
The downside of this approach is that the user **sees a flash of defualt settings** before user's preferences load.
⠀
### Showing loading state while settings are loading
If your application depends heavily on user settings to render the initial layout (e.g., to avoid layout shift), you can check the `hasBeenHydrated` or `isLoadPending` flags.
You can access these flags by passing a selector to `useConfig`:
```tsx
import {useConfig} from './settings/hooks';
export const App = () => {
// Select the entire state to access status flags
const isLoaded = useConfig((_c, state) => state.hasBeenHydrated);
// OR check pending specifically
const isLoading = useConfig((_c, state) => state.isLoadPending);
if (isLoading) {
return ;
}
return ;
};
```
⠀
## Showing error state when setting failed to load
If the adapter fails to read (e.g., network timeout), the manager enters an error state. You can detect this locally in a component or globally via callbacks.
⠀
### Local handling (Try-Catch)
The `manager.load()` method returns a `Promise`. If the adapter fails to read, the promise rejects (in addition to setting the internal error state).
You can wrap the call in a standard `try-catch` block to handle initialization errors imperatively. This is common in the application entry point (`main.tsx`) to prevent the app from mounting if critical configuration is missing.
```tsx
// src/main.tsx
async function init() {
let app: ReactNode = ;
try {
// Wait for settings to load
await configManager.load();
} catch (error) {
// If it fails, render a specific fatal error screen
// instead of the main application.
app = ;
}
// Render app only on success
ReactDOM.createRoot(document.getElementById('root')!).render(app);
}
init();
```
⠀
### React-specific handling (Route Loaders)
In modern React architectures (like **TanStack Router** or **React Router 6.4+**), the recommended way to handle async errors is via **Route Loaders** and **Error Boundaries**.
Instead of managing `try-catch` blocks or `useEffect` hooks, you simply return `configManager.load()` in your root route's loader. If the promise rejects, the router automatically catches it and renders the configured Error Component.
Note: If your settings require authentication, you should avoid calling `load()` for anonymous users. This prevents unnecessary network requests that would result in 401 errors. Since the `ConfigManager` is already initialized with default values, you can simply **skip** the loading process if the user is not logged in.
```tsx
// src/routes/__root.tsx
import {createRootRoute} from '@tanstack/react-router';
import {configManager} from '../settings/manager';
import {authStore} from '../auth/store';
export const Route = createRootRoute({
loader: async () => {
// 1. Check if user is logged in
const {isAuthenticated} = authStore.getState();
// 2. Only fetch settings if authenticated
if (isAuthenticated) {
await configManager.load();
}
// 3. Otherwise, do nothing. The app will proceed using
// the default config defined in your Zod schema.
},
component: RootLayout,
});
```
⠀
### Global handling
When creating the manager, you can provide an `onLoadError` callback. This is useful for logging to services like Sentry.
```ts
const manager = ConfigManager.create(
{
adapter,
onLoadError: (error) => {
console.error('Failed to load settings', error);
Sentry.captureException(error);
toast.error('Failed to load settings');
},
},
{
/* ... */
}
);
```
## Showing error state when setting failed to save
When you call `update()`, the library performs an **Optimistic Update**. The store updates immediately. If the adapter fails to write the change, the store sets `isSaveError` to true.
The `useUpdateConfig` hook returns the status of the _current_ update operation.
```tsx
export const SaveButton = () => {
const {update, isPending, isError, error} = useUpdateConfig();
return (
);
};
```
## Showing a toast when setting failed to save
For a better UX, you usually want to show a toast notification if a background save fails, rather than handling `isError` in every single component.
You can configure a global `onSaveError` handler when initializing the manager.
```ts
import {ConfigManager, LocalStorageAdapter} from '@config-store/core';
import {z} from 'zod';
import toast from 'react-hot-toast'; // or your preferred library
export const configManager = ConfigManager.create(
// Config manager options
{
adapter: new LocalStorageAdapter(),
// Global error handler
onSaveError: (error) => {
const message = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Could not save settings: ${message}`);
},
// You can also handle migration errors here
onMigrationError: ({error}) => {
toast.error('Failed to migrate settings version');
console.error(error);
},
},
// Initial version
{
version: 1,
schema: z
.object({
/* ... */
})
.prefault({}),
}
);
```
# Frequently Asked Questions
### Should I use TanStack Query in the adapter?
**Probably not.**
TanStack Query (React Query) is designed for **Server State**. This library manages **Client State**. If you use TanStack Query inside the adapter, you are effectively caching the data twice.
If you know what you're doing, you _can_ bridge them using `queryClient.fetchQuery` inside `adapter.read()` and `queryClient.setQueryData` inside `adapter.write()`.
⠀
### Why does the library depend on Zustand?
The `@config-store/core` package uses `zustand/vanilla` internally as a micro-dependency (<1kb) for the underlying store. The `@config-store/react` package uses `zustand`.
Zustand provides a robust store implementation with selector support, e. g. `useConfig(s => s.theme)`. This prevents unnecessary re-renders that would occur with standard React Context. For example, if a component relies on property A to render, then changes to property B should not cause the component to rerender.
⠀
### What's the hassle with migrations?
Config schemas change over time as your project matures. For example, dark theme was managed via `darkTheme: boolean` setting, but now it's `theme: 'dark' | 'light' | 'system'`.
Without migrations, a user returning after 6 months will experience a crash because their localStorage data doesn't match your new code. To work around the crash, you'll have to reset their settings to defaults.
`@config-store` lets you define a migration, that will change the user's config to the new format without discarding their preferences.
Migrations are applied transparently, keeping your UI code clean and typed strictly to the _latest_ version.
⠀
### What happens if I omit a migration?
If a new config version does not define a migration, user settings will be used as-is for this version. This is fine in cases where your schema changes are minor and do not result in different types.
However, if `adapter.read()` returns data that does not match the current Zod schema, **Zod will throw a validation error**. The `ConfigManager` catches this error, logs it, and **falls back to default values** to prevent a White Screen of Death. With proper migrations set up, this should only happen when settings are edited in the database in violation of the frontend schema.
⠀
### How do I reset a setting to its default value?
Pass `undefined` to the update hook: `updateConfig({ theme: undefined })`. Zod will apply the `.default()` value defined in your schema.