# Plugins — How To Guide Plugins let you extend cogsbox-state without changing the core library. Use them when you want reusable behaviour — validation, uploads, default state, field wrappers, analytics — that plugs into the state lifecycle. This guide shows how to build and wire up plugins. For chain method helpers (`path`, `array`, etc.), see the **Plugin Chain Methods** section in [README.md](./README.md). --- ## 1. Create a plugin Use `createPluginContext` and chain the hooks you need: ```ts import { createPluginContext } from 'cogsbox-state'; import { z } from 'zod'; const { createPlugin } = createPluginContext({ options: z.object({ multiplier: z.number(), }), }); export const scalePlugin = createPlugin('scale') .useHook((params) => { return { factor: params.options.multiplier * 10 }; }) .transformState((params) => { if (params.isInitialTransform && params.hookData) { params.initialiseState({ value: params.hookData.factor }); } }); ``` You can chain any of these (in any order, each once): | Method | Use when… | |--------|-----------| | `.initialState()` | The plugin should contribute default state keys | | `.useHook()` | You need a React hook (fetch data, subscriptions, etc.) | | `.transformState()` | Options change and you need to reshape state | | `.onUpdate()` | You want to react to every state write | | `.onFormUpdate()` | You want to react to focus/blur/input on form fields | | `.formWrapper()` | You want to wrap rendered form elements | | `.methods()` | You want custom chain methods on state nodes | Options and metadata schemas are optional — skip them for plugins that don't need config. --- ## 2. Register the plugin Pass plugins when you create state: ```ts const { useCogsState } = createCogsState(initialState, { plugins: [scalePlugin], }); ``` ### Contributing default state If your plugin defines `.initialState()`, its keys are merged in at setup time. **Your `initialState` argument always wins** on conflicts: ```ts const counterPlugin = createPlugin('counter').initialState(() => ({ count: 0, })); // Plugin provides `count` createCogsState({}, { plugins: [counterPlugin] }); // User override wins — count starts at 100 createCogsState({ count: 100 }, { plugins: [counterPlugin] }); ``` Plugin-only keys (like `filter` below) are still added even when you pass partial state: ```ts const taskPlugin = createPlugin('tasks').initialState(() => ({ tasks: [] as Todo[], filter: 'all', })); createCogsState({ theme: 'dark' }, { plugins: [taskPlugin] }); // → { theme: 'dark', tasks: [], filter: 'all' } ``` --- ## 3. Pass plugin options Options are keyed by the plugin's `name`. Pass them from `useCogsState` or `setCogsOptionsByKey`: ```ts const counter = useCogsState('counter', { scale: { multiplier: 5 }, }); ``` Or set them later: ```ts setCogsOptionsByKey('counter', { scale: { multiplier: 5 }, }); ``` --- ## 4. Wrap your app with `PluginRunner` Plugins that use `.useHook()` need `PluginRunner` so hooks actually run: ```tsx import { PluginRunner } from 'cogsbox-state'; function App() { return ( ); } ``` Each `(stateKey, plugin)` pair gets its own instance inside the runner. --- ## 5. Common recipes ### React side-effects with `useHook` Run setup/teardown once per state key. Return anything you want available in later hooks: ```ts createPlugin('analytics') .useHook(() => { const client = createAnalyticsClient(); useEffect(() => () => client.flush(), []); return { client }; }) .onUpdate((params) => { params.hookData?.client.track('state-change', params.update); }); ``` ### Transform state when options change `transformState` runs when plugin options change (and once on mount). Use `isInitialTransform` to distinguish first run: ```ts createPlugin('seed') .transformState((params) => { if (params.isInitialTransform) { params.initialiseState({ items: params.options.defaultItems }); } }); ``` Inside any hook you also get state helpers: `getState()`, `initialiseState()`, `initialiseShadowState()`, `applyOperation()`, `addZodErrors()`, and DOM helpers like `setFieldDisabled()`. ### React to state updates ```ts createPlugin('logger') .onUpdate((params) => { console.log(params.update.path, params.update.oldValue, '→', params.update.newValue); }); ``` ### Validate on blur `onFormUpdate` receives form activity events. Blur is the usual trigger for field validation: ```ts createPlugin('validator') .onFormUpdate((params) => { if (params.event.activityType !== 'blur') return; const result = mySchema.safeParse(params.getState()); if (!result.success) { params.addZodErrors( result.error.issues.map((i) => ({ path: i.path.map(String), message: i.message, code: i.code, })) ); } }); ``` ### Cross-field validation (shape-style) When one field's error depends on others, validate the full state but only surface errors for the blurred field and its related fields: ```ts createPlugin('shapeValidator') .onFormUpdate((params) => { if (params.event.activityType !== 'blur') return; const path = params.event.path; const fieldName = path[path.length - 1]; const { schema, fieldToGroup, groups } = params.options; const affectedGroups = fieldToGroup[fieldName] ?? []; const relatedFields = new Set(); for (const g of affectedGroups) { for (const dep of groups[g].deps) relatedFields.add(dep); } const result = schema.safeParse(params.getState()); if (!result.success) { const errors = result.error.issues .filter((i) => { const issueField = String(i.path[0]); return issueField === fieldName || relatedFields.has(issueField); }) .map((i) => ({ path: i.path.map(String), message: i.message, code: i.code, })); if (errors.length) params.addZodErrors(errors); } }); ``` The core library doesn't know about schemas — it just forwards the event and applies whatever errors you return. ### Wrap form fields Use `.formWrapper()` on the plugin, or set it dynamically via options: ```ts // On the plugin createPlugin('highlight').formWrapper((params) => (
{params.element}
)); // Or dynamically setCogsOptionsByKey('myForm', { formElements: { highlight: (params) =>
{params.element}
, }, }); ``` ### Add chain methods Use `.methods()` to attach actions to state nodes. See [README.md](./README.md#plugin-chain-methods) for the full `path` / `array` / `object` API. ```ts const s3Plugin = createPlugin('s3') .methods(({ array }) => ({ uploadAll: array(async (ctx, bucket: string) => { await uploadMany(ctx.$get(), bucket); }), })); // Real state fields win over plugin methods with the same name ``` --- ## 6. Typed options (optional) If your plugin takes config, define a Zod schema in `createPluginContext`: ```ts const { createPlugin } = createPluginContext({ options: z.object({ schema: z.custom(), refineInfo: z.custom(), }), pluginMetaData: z.object({ lastValidatedAt: z.number().optional() }), fieldMetaData: z.object({ touched: z.boolean().optional() }), }); ``` `params.options`, metadata getters/setters, and the builder chain are all typed from these schemas. --- ## Quick reference — when to use what | Goal | Hook | |------|------| | Default state keys | `.initialState()` | | Fetch/subscribe in React | `.useHook()` | | Reshape state when config changes | `.transformState()` | | Log/sync on every write | `.onUpdate()` | | Validate on blur/input | `.onFormUpdate()` | | Custom field UI | `.formWrapper()` or `formElements` option | | Custom actions on state (`state.items.upload()`) | `.methods()` |