This is the full 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 (