feature-react connects React components to feature-state and feature-form objects outside the React tree. Hooks subscribe directly, so no provider is required and computed hooks re-render only when selected values change.
- Use module, service, or form state directly from components
- Derive slices with
useComputeinstead of re-rendering on every source update - Bind
feature-formfields with focused field subscriptions - Pass
nullfor conditional subscriptions without breaking hook rules
import { createForm } from 'feature-form';
import { useFormField } from 'feature-react/form';
import { useCompute } from 'feature-react/state';
import { createState } from 'feature-state';
import * as z from 'zod';
type TTask = { id: string; title: string; done: boolean };
const $tasks = createState<TTask[]>([]);
const $profileForm = createForm({
fields: {
email: { defaultValue: '', validator: z.string().email(), validateOn: ['blur'] }
}
});
const CompletedCount = () => {
const count = useCompute($tasks, (tasks) => tasks.filter((t) => t.done).length);
return <span>{count} completed</span>;
};
const EmailField = () => {
const { input, status } = useFormField($profileForm, 'email');
return (
<label>
Email
<input {...input()} />
{status.type === 'invalid' && <span>{status.errors[0].message}</span>}
</label>
);
};Migrating from 0.0.x? See MIGRATION.md.
npm install feature-react reactInstall the matching state or form package for the hooks you use:
# State hooks
npm install feature-state
# Form hooks
npm install feature-formInstall zod only for examples or any form that uses Zod validators. Any Standard Schema validator works with feature-form.
The React peer dependency supports React 18 and React 19.
Pick the hook surface that matches the source of truth:
feature-react/state: subscribe tofeature-statevalues, derived values, listeners, and subscribersfeature-react/form: bindfeature-formforms and fields to React inputs- State features: add browser storage or global bindings for state objects
Use useFeatureState to subscribe a component to a state value and re-render when it changes. Use useCompute when you only care about a derived slice: the component skips re-renders unless the computed result itself changes.
import { useCompute, useFeatureState } from 'feature-react/state';
import { createState } from 'feature-state';
type TTask = { id: string; title: string };
const $tasks = createState<TTask[]>([]);
export const Tasks = () => {
const tasks = useFeatureState($tasks);
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
};Bind a form with useForm to get input helpers and a submit handler in one call:
import { createForm } from 'feature-form';
import { useForm } from 'feature-react/form';
const $form = createForm<{ name: string; email: string }>({
fields: {
name: { defaultValue: '' },
email: { defaultValue: '' }
}
});
export const ContactForm = () => {
const { input, handleSubmit } = useForm($form);
return (
<form onSubmit={handleSubmit({ onValidSubmit: console.log })}>
<input {...input('name')} />
<input {...input('email')} />
<button type="submit">Submit</button>
</form>
);
};Returns the current state value and re-renders the component when the state changes.
import { useFeatureState } from 'feature-react/state';
import { createState } from 'feature-state';
type TTask = { id: string; title: string };
const $tasks = createState<TTask[]>([]);
export const Tasks = () => {
const tasks = useFeatureState($tasks);
return (
<ul>
{tasks.map((task) => (
<li key={task.id}>{task.title}</li>
))}
</ul>
);
};Passing null or undefined returns null without subscribing. Background updates do not trigger an immediate re-render.
Derives a computed value from one state or a tuple of states. The component re-renders only when the computed result changes.
import { useCompute } from 'feature-react/state';
// Single state
const completedCount = useCompute($tasks, (tasks) => tasks.filter((t) => t.done).length);
// Multiple states
const filtered = useCompute([$tasks, $filter], ([tasks, filter]) =>
tasks.filter((t) => t.category === filter)
);deps lists any values that compute reads outside the subscribed state. When deps change, the computed value is recalculated.
isEqual defaults to Object.is. Pass a custom comparator to suppress re-renders when the computed structure is equivalent but not referentially identical. Pass false to always re-render on any input change.
compute and isEqual must stay pure because React may call them during render.
Calls callback whenever the state changes without subscribing the component to re-renders. Use this for side effects triggered by state changes.
import { useListener } from 'feature-react/state';
export const Analytics = () => {
useListener($tasks, ({ value: tasks }) => {
analytics.track('tasks_changed', { count: tasks.length });
});
return null;
};The callback runs after every state change, including background updates. It may return a cleanup function that runs before the next callback and on unmount. Async callbacks are accepted but not awaited.
Like useListener, but runs the callback immediately on mount with the current state value.
import { useSubscriber } from 'feature-react/state';
useSubscriber($theme, ({ value: theme }) => {
document.documentElement.setAttribute('data-theme', theme);
});Subscribes a component to a form and re-renders when any field changes. Use this when a single component owns the whole form.
import { createForm } from 'feature-form';
import { useForm } from 'feature-react/form';
interface TFormData {
name: string;
email: string;
}
const $form = createForm<TFormData>({
fields: {
name: { defaultValue: '' },
email: { defaultValue: '' }
}
});
export const ContactForm = () => {
const { input, handleSubmit } = useForm($form);
return (
<form onSubmit={handleSubmit({ onValidSubmit: console.log })}>
<input {...input('name')} />
<input {...input('email')} />
<button type="submit">Submit</button>
</form>
);
};Return value
| Property | Description |
|---|---|
form |
The underlying TForm instance |
input(key, options?) |
Returns props for a native input, textarea, or select. See input options |
handleSubmit(options?) |
Returns an event handler. Calls event.preventDefault() by default. Options: onValidSubmit(data), onInvalidSubmit(errors), preventDefault (default true) |
field(key) |
Returns the TFormField for the given key |
status(key) |
Returns the field's status state. Pass it to useFeatureState to subscribe to status changes for a specific field |
Subscribes to a single field's status and returns input props for uncontrolled fields by default. Use this for isolated field components or large forms where re-rendering on every keystroke is expensive.
import { useFormField } from 'feature-react/form';
export const NameField = () => {
const { status, input } = useFormField($form, 'name');
return (
<div>
<input {...input()} />
{status.type === 'invalid' && <span>{status.errors[0]?.message}</span>}
</div>
);
};Pass { controlled: true } when React should subscribe to and render the field value:
const { value, status, input } = useFormField($form, 'name', { controlled: true });Return value
| Property | Description |
|---|---|
field |
The TFormField instance for the given key |
value |
The current field value. Only returned when controlled: true |
status |
The current validation status value |
input() |
Returns props for a native input, textarea, or select |
Builds input props from a TFormField directly, without a hook. Use this outside components or when you already hold the field reference.
useForm().input() and getFieldInputProps() accept options per call. useFormField() accepts controlled at the hook level, and its returned input() helper accepts format and parse.
For string-valued fields, all options are optional:
| Option | Description |
|---|---|
controlled |
When true, renders as a controlled input. On useFormField, pass this to the hook instead of a per-call option. |
format |
Maps the field value to a display string |
parse |
Maps the input string back to the field value |
For non-string fields, format and parse are required.
// useForm: options per input call
input('name');
input('age', { format: (v) => String(v), parse: (s) => Number(s), controlled: true });
// useFormField: controlled goes to the hook; format and parse go to input()
const { input: ageInput } = useFormField($form, 'age', {
controlled: true
});
ageInput({ format: (v) => String(v), parse: (s) => Number(s) });Features are installed via .with() and extend a state with new capabilities.
Persists state in localStorage. Built on top of storageFeature from feature-state.
import { localStorageFeature, useFeatureState } from 'feature-react/state';
import { createState } from 'feature-state';
const $theme = createState<'light' | 'dark'>('light').with(localStorageFeature('theme'));
await $theme.persist();
export const ThemeToggle = () => {
const theme = useFeatureState($theme);
return <button onClick={() => $theme.set(theme === 'light' ? 'dark' : 'light')}>{theme}</button>;
};persist() loads any previously saved value. If nothing is stored it saves the current value instead, then auto-saves on every subsequent set(). See storageFeature in the feature-state README for the full contract.
Exposes the state on globalThis[key] for debugging in the browser console.
import { globalBindFeature } from 'feature-react/state';
import { createState } from 'feature-state';
const $tasks = createState<string[]>([]).with(globalBindFeature('_tasks'));
// In the browser console:
// globalThis._tasks.get()feature-react is a binding layer, not a state model by itself. Use it when your state already lives in feature-state or your forms already live in feature-form, and React should subscribe to those objects without providers.
- zustand: store-based state with a built-in selector hook
- jotai: atom-based state defined outside components
- React context: built-in context API that re-renders consumers when the provided value changes
Use useFormField when a field component should re-render only on its own status changes. With useForm, any field change in the form re-renders the whole component. For large forms, useFormField in isolated field components is significantly cheaper.
Use status(key) when you want to subscribe to a single field's status from within a component that already calls useForm. Pass the returned state to useFeatureState to get a focused subscription without adding a second useFormField call.
useListener runs the callback only on subsequent state changes. useSubscriber also runs it immediately on mount with the current value. Use useSubscriber when the side effect must reflect the current state on first render, such as syncing a DOM attribute.
Use useListener when you need to react to state changes as a side effect but the component does not render anything derived from that state. Avoids an unnecessary re-render.
Yes. Both hooks use a stable callback ref internally, so you can close over other state or props without stale value issues. Async callbacks are accepted but not awaited.
Yes. State hooks such as useFeatureState, useCompute, useListener, and useSubscriber accept null and undefined without subscribing. useFeatureState returns null in that case. This makes conditional subscription safe without violating the rules of hooks.
States can emit updates marked as background, meaning the change should be picked up on the next render rather than forced immediately. useFeatureState and useCompute record the change so the next render reflects it, but they do not schedule an extra re-render.
It disables the equality check entirely. The component re-renders every time any subscribed state emits a change, regardless of whether the computed value actually changed. This is useful when every source notification should produce a fresh rendered value. Keep compute pure. Use useListener for side effects.
Yes. notify() signals a change regardless of whether the value reference changed. useFeatureState uses useSyncExternalStore with a snapshot wrapper, so a notify() call produces a new snapshot and triggers a re-render.