Skip to main content

Comparing with React Context and useReducer

This page is for people who think "we don't need a state library: React already has Context, useState and useReducer". It shows how to get the same results with Kiss. Each section shows the plain React code first, and then the Kiss code that does the same.

React has three tools for state. useState keeps a value inside a component. useReducer does the same, but moves the changes into a reducer: a function that gets the state and an action, and returns the new state. And Context passes a value down the tree, so that any component below can read it without props. The React documentation puts the three together, in a pattern called "scaling up with a reducer and context": a provider component holds the reducer, and passes the state and the dispatch function down through two contexts. React 19 adds Actions: async functions run in a transition, with useTransition, useActionState and useOptimistic.

Kiss is based on immutable state and actions. There is one state object, and it never changes. An action takes the current state and returns a new one. Components read the state with a selector, and re-render when the selected value changes.

So the ideas are close. A Kiss action and its reducer come from the same place as useReducer: you dispatch something, and a reducer returns the new state. The difference is in what React leaves to you. Every time React "dispatches to a pure reducer, after the async work was done in a component", Kiss "dispatches an action whose reducer does the async work itself". And every time React "re-renders every component that reads the context", Kiss "re-renders only the components whose selected value changed".

The big difference​

Plain React gives you the parts of a store, not the store. That is fine, and often the right call, for a small app, or for state that belongs to one component. There is no dependency to add, nothing to learn, and the React team maintains it. If a dropdown is open, or what the user typed before pressing "Send", that state belongs to the component, and useState is the right tool, with or without Kiss.

The trouble starts with state that the app shares. Then you build the store yourself: a context, a provider, a reducer, custom hooks, loading flags, and an effect for each request. Each team, and each feature, builds it a little differently. Kiss is that store, already built.

Three things follow from this, and they come up in every section below:

  • Async logic has a home. A useReducer reducer must be pure, and can't await. So the requests, the loading flags, and the error handling live in components: in effects, in event handlers, or in React 19 Actions. In Kiss, an action can be async, and the store tracks if it's running or failed.

  • Components read only what they need. Context has no selectors. When the value of a context changes, every component that reads it re-renders, even if the part it uses didn't change. In Kiss, useSelect picks a part of the state, and the component re-renders only when that part changes.

  • The state lives outside the component tree. With Context, the state lives inside a provider component. Only components below it can reach it. Code outside React, like a WebSocket handler, a timer, or a test, can't. In Kiss, the store is an object you create. Components, actions, and plain code all reach it the same way.

React (Context and useReducer)Kiss
Where the state livesInside a provider componentIn a store you create
Changing the statedispatch({ type }) to a pure, sync reducerAn action class with reduce()
Async changesIn components: effects, event handlers, React 19 Actionsasync reduce()
Loading and failed statesuseState flags, or isPending, in one componentuseIsWaiting, useIsFailed, anywhere
Re-rendersEvery component that reads the contextOnly the ones whose selection changed
Derived valuesuseMemo in each componentGetters, selectors, cached fields
Race conditionsAn ignore flag in each effectignoreOld
Retry, debounce, freshBy handAction features
Optimistic updatesuseOptimistic, in one componentOptimisticCommand, OptimisticSync
Code outside componentsCan't reach the statestore.state, store.dispatch
PersistenceBy handThe persistor
DependenciesA contextInjected into the store
Testing the logicReducers: yes. Async logic: by rendering.Yes, with one store per test

Quick translation​

If you know React's own tools, this table tells you where to look in Kiss. The sections below explain each line, with code.

ReactKiss
useReducer(reducer, initialState) inside a provider componentcreateStore({ initialState })
A state context, a dispatch context, and a provider for each featureOne StoreProvider at the top of the app
Custom hooks like useTodos() and useTodosDispatch()useSelect, useDispatch
useContext(TodosContext)useSelect((state) => state.todos)
Splitting contexts, useMemo on the value, use-context-selectorNot needed. useSelect and useObject select.
An action, like { type: 'added', todo }An action, like new AddTodo(todo)
A case 'added': in the reducer's switchThe reduce() method of AddTodo
A union type of all the actionsNot needed. Each action class is its own type.
useEffect that fetches, with an ignore flaguseStoreEffect({ onMount }), and an async action with ignoreOld
isLoading and error kept with useStateuseIsWaiting(Action), useIsFailed(Action), useExceptionFor(Action)
useTransition with an async function, and isPendingAn async action, and useIsWaiting(Action)
useActionState(reducerAction, initialState)An async action. Its result goes in the state.
useOptimisticOptimisticCommand, OptimisticSync
use(promise) with <Suspense>useSuspense(Action)
An error boundary around a failed ActionwrapError() and UserException, or useSuspense
useMemo for a derived valueA getter on the state class, or a Selector class
A context that holds the API clientdependencies in createStore, read with this.deps
useEffect that saves to localStorage, and a lazy initial stateThe persistor
A dispatch saved in a module variable, to use outside Reactstore.dispatch(action), store.state
renderHook with the provider as wrappernew Store(...), then dispatchAndWait
Retry, debounce, throttle, fresh, non-reentrantretry, debounce, throttle, fresh, nonReentrant

Creating the store​

In React, a feature's shared state is a provider component. It holds the reducer, and passes the state and the dispatch function down through two contexts. Two custom hooks read them, and check that the provider is there:

React
const TodosContext = createContext<Todo[] | null>(null);
const TodosDispatchContext = createContext<Dispatch<TodoAction> | null>(null);

export function TodosProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todosReducer, []);
return (
<TodosContext value={todos}>
<TodosDispatchContext value={dispatch}>
{children}
</TodosDispatchContext>
</TodosContext>
);
}

export function useTodos() {
const todos = useContext(TodosContext);
if (todos === null) throw new Error('useTodos must be used inside a TodosProvider.');
return todos;
}

export function useTodosDispatch() {
const dispatch = useContext(TodosDispatchContext);
if (dispatch === null) throw new Error('useTodosDispatch must be used inside a TodosProvider.');
return dispatch;
}

That is one feature. The user, the settings, and the filter each get the same: two contexts, a provider, and two hooks. The providers then nest at the top of the app, in an order that matters, because a provider can only read the providers above it:

React
<UserProvider>
<SettingsProvider>
<TodosProvider>
<App />
</TodosProvider>
</SettingsProvider>
</UserProvider>

In Kiss, the state is one class, with a field for each of those features. The fields are readonly, and copy() returns a new state with some fields changed. The store gets the initial state, and one StoreProvider passes it to the components:

Kiss
class State {
constructor(
readonly user: User | null,
readonly settings: Settings,
readonly todos: readonly Todo[],
) {}

copy(changes: Partial<State>): State {
return Object.assign(new State(this.user, this.settings, this.todos), changes);
}

static initialState = new State(null, Settings.default, []);
}

const store = createStore<State>({ initialState: State.initialState });

<StoreProvider store={store}>
<App />
</StoreProvider>

The hooks come with Kiss, and work for every field. There are no contexts or custom hooks to write. See Store and state and Provide the store.

Changing the state​

In React, the actions are plain objects with a type. You list them in a union type, and the reducer handles each one in a switch:

React
type TodoAction =
| { type: 'added'; todo: Todo }
| { type: 'removed'; id: string };

function todosReducer(todos: Todo[], action: TodoAction): Todo[] {
switch (action.type) {
case 'added': return [...todos, action.todo];
case 'removed': return todos.filter((todo) => todo.id !== action.id);
}
}

// In a component.
const dispatch = useTodosDispatch();
dispatch({ type: 'added', todo });

This is clean, and it is the same idea as Kiss. The differences are in the details. Each action is written in three places: the union, the switch, and the dispatch. And the reducer only sees its own state. If adding a todo needs the user, the reducer can't read UserContext, so the component must read it and put it in the action.

In Kiss, an action is a class. Its constructor parameters are the data it needs, and its reduce() method returns the new state. The class is the type, the payload, and the reducer, in one place. And this.state is the whole state, so the action can read the user, the settings, and the todos:

Kiss
class AddTodo extends Action {
constructor(readonly todo: Todo) { super(); }
reduce() { return this.state.copy({ todos: [...this.state.todos, this.todo] }); }
}

class RemoveTodo extends Action {
constructor(readonly id: string) { super(); }
reduce() { return this.state.copy({ todos: this.state.todos.filter((todo) => todo.id !== this.id) }); }
}

// In a component.
const dispatch = useDispatch();
dispatch(new AddTodo(todo));

There is no union to keep up to date, and no switch. In your IDE, you can click AddTodo where it's dispatched and land on its reducer. See Actions and reducers.

Reading the state, and re-renders​

Context has no selectors. A component reads the whole value of the context. When the value changes, React re-renders every component that reads that context, and wrapping a component in memo doesn't stop it. So if one context holds several things, a component that shows only the user's name re-renders every time a todo changes:

React
function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
const value = useMemo(() => ({ state, dispatch }), [state]);
return <AppContext value={value}>{children}</AppContext>;
}

function UserName() {
const { state } = useContext(AppContext)!;
return <p>{state.user.name}</p>; // Re-renders when the todos change, too.
}

Note the useMemo. Without it, the { state, dispatch } object is new on every render of the provider, and every reader re-renders even when nothing changed. The React documentation recommends it, along with useCallback for functions in the value.

In a small app, those extra renders don't matter. In a bigger one, the usual fixes are to split the state into many small contexts (the provider pyramid shown above), or to add a library like use-context-selector, which adds selectors to Context.

In Kiss, a component gives useSelect a selector, and re-renders only when the selected value changes. UserName doesn't re-render when a todo changes:

Kiss
function UserName() {
const name = useSelect((state: State) => state.user?.name);
return <p>{name}</p>;
}

To select several values at once, use useObject, which compares the new object with the previous one, value by value:

Kiss
const { name, todoCount } = useObject((state: State) => ({
name: state.user?.name,
todoCount: state.todos.length,
}));

The hooks connect to the store with useSyncExternalStore, React's own hook for reading a store that lives outside React. See Components use the state.

One trade-off, to be fair: a state change in useState or useReducer can be rendered as a transition, which React may pause to keep the screen responsive. Updates from a store read with useSyncExternalStore are always rendered right away. See How the hooks follow the store.

Async changes​

Since the reducer must be pure, React does the async work in the component, before dispatching. Loading data when the screen opens is an effect. The loading flag and the error go in useState. And, as the React documentation explains, the effect needs an ignore flag, so that a slow old response doesn't overwrite a newer one:

React
function TodoList() {
const todos = useTodos();
const dispatch = useTodosDispatch();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
let ignore = false;
api.loadTodos()
.then((todos) => { if (!ignore) dispatch({ type: 'loaded', todos }); })
.catch((error) => { if (!ignore) setError(error); })
.finally(() => { if (!ignore) setIsLoading(false); });
return () => { ignore = true; };
}, [dispatch]);

if (isLoading) return <Spinner />;
if (error) return <p>{error.message}</p>;
return <ul>{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;
}

This works, and it's what most React apps do. But look at where things ended up:

  • The loading flag and the error belong to this component. A spinner in the header can't know that the todos are loading, unless you move the flags into the context too, and set them in the reducer, by hand.
  • If two components need the todos, both fetch them, or you decide which one is in charge.
  • In development, StrictMode runs the effect twice, so the request is sent twice. The ignore flag throws one result away, but both requests still hit the server.
  • The logic is inside a component, so testing it means rendering the component.

In Kiss, an action can be async. Its reduce() method awaits what it needs, and returns a function that produces the new state. If something goes wrong, the action throws, and the state is not changed:

Kiss
class LoadTodos extends Action {
ignoreOld = true; // A newer LoadTodos makes this one old: its result is ignored.

async reduce() {
const todos = await this.deps.api.loadTodos(this.abortSignal);
return (state: State) => state.copy({ todos });
}
}

function TodoList() {
const todos = useSelect((state: State) => state.todos);
const isWaiting = useIsWaiting(LoadTodos);
const isFailed = useIsFailed(LoadTodos);
const error = useExceptionFor(LoadTodos);

useStoreEffect({ onMount: (store) => store.dispatch(new LoadTodos()) });

if (isWaiting) return <Spinner />;
if (isFailed) return <p>{error?.message}</p>;
return <ul>{todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>;
}

There are no isLoading and error states, because loading and failure are not state you keep. They are questions you ask about the action: is LoadTodos running? Did it fail? Any component can ask, including the header. useStoreEffect runs onMount once, even in StrictMode. And ignoreOld does what the ignore flag did, and also cancels the old request, through this.abortSignal. See Wait, fail, succeed and Dispatching when the component mounts.

To turn any error into a message for the user, override wrapError() in the action. A UserException is shown to the user in a dialog, by default:

Kiss
class LoadTodos extends Action {
async reduce() { ... }

wrapError(error: any) {
return new UserException('Could not load the todos.', { hardCause: error });
}
}

React 19 Actions​

React 19 gives async work a better home than effects, for changes that start with the user: a button, a form. You pass an async function to startTransition, and isPending is true until it finishes. React calls this function an Action:

React
function SaveButton({ text }: { text: string }) {
const dispatch = useTodosDispatch();
const [isPending, startTransition] = useTransition();

function save() {
startTransition(async () => {
const todo = await api.saveText(text);
startTransition(() => { dispatch({ type: 'added', todo }); });
});
}

return <button onClick={save} disabled={isPending}>Save</button>;
}

Note the second startTransition. The React documentation says that, for now, state updates after an await must be wrapped again, or they are not part of the transition. If the Action throws, React shows the nearest error boundary.

For forms, useActionState goes further. Its function looks like a reducer that can be async: it gets the previous state and a payload, and returns the next state. Calls are queued, and run one after the other. Known errors are returned as part of the state:

React
function NewTodoForm() {
const [result, formAction, isPending] = useActionState(
async (previous: { error: string | null }, formData: FormData) => {
try {
await api.addTodo(String(formData.get('title')));
return { error: null };
} catch (error) {
return { error: 'Could not add the todo.' };
}
},
{ error: null },
);

return (
<form action={formAction}>
<input name="title" />
<button disabled={isPending}>Add</button>
{result.error && <p>{result.error}</p>}
</form>
);
}

These are good tools, and they fix real problems of effects. But they are tied to the component that calls the hook. The isPending of NewTodoForm is known only to NewTodoForm. The state of useActionState lives in that component, and goes away when it unmounts. And the todo list still doesn't know a todo was added, unless the Action also dispatches to the todos context. The logic is still in the UI, so testing it still means rendering it.

In Kiss, the same work is an action. The component dispatches it, and any component can ask if it's running or failed. The result goes in the state, where the todo list reads it:

Kiss
class AddTodo extends Action {
constructor(readonly title: string) { super(); }

async reduce() {
const todo = await this.deps.api.addTodo(this.title);
return (state: State) => state.copy({ todos: [...state.todos, todo] });
}

wrapError(error: any) {
return new UserException('Could not add the todo.', { hardCause: error });
}
}

function NewTodoForm() {
const dispatch = useDispatch();
const isAdding = useIsWaiting(AddTodo);

return (
<form action={(formData) => { dispatch(new AddTodo(String(formData.get('title')))); }}>
<input name="title" />
<button disabled={isAdding}>Add</button>
</form>
);
}

useActionState runs its calls one after the other. In Kiss that's a choice you make per action: add sequential to queue them, nonReentrant to ignore a second click while the first one runs, or nothing, to let them run in parallel.

Suspense and use​

React 19's use(promise) suspends the component until the promise resolves, and throws to the nearest error boundary if it rejects. But the promise can't be created during the render of a client component, because it would be a new promise on every render. The React documentation says to get it from a Server Component, or from a cache, such as a Suspense-enabled framework or library. So use reads data, but something else must load it and keep it:

React
function Albums({ albumsPromise }: { albumsPromise: Promise<Album[]> }) {
const albums = use(albumsPromise);
return <ul>{albums.map((album) => <li key={album.id}>{album.title}</li>)}</ul>;
}

In Kiss, the action loads the data and puts it in the state, which is the cache. useSuspense suspends the component while the action runs, and throws to the error boundary if it failed:

Kiss
function Albums() {
useSuspense(LoadAlbums);
const albums = useSelect((state: State) => state.albums);
return <ul>{albums.map((album) => <li key={album.id}>{album.title}</li>)}</ul>;
}

<ErrorBoundary fallback={<p>Could not load the albums.</p>}>
<Suspense fallback={<Spinner />}>
<Albums />
</Suspense>
</ErrorBoundary>

See Suspense and error boundaries.

Optimistic updates​

React 19 has useOptimistic. Inside an Action, you set a value that is shown right away. When the Action ends, React goes back to the real value. If the Action failed, the real value didn't change, so the screen shows what it showed before:

React
function LikeButton({ isLiked, onToggle }: { isLiked: boolean; onToggle: (liked: boolean) => Promise<void> }) {
const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);

function handleClick() {
startTransition(async () => {
const next = !optimisticLiked;
setOptimisticLiked(next); // Shown right away.
await onToggle(next); // Saves, and updates the real isLiked.
});
}

return <button onClick={handleClick}>{optimisticLiked ? 'Unlike' : 'Like'}</button>;
}

This is simple, and good for one button. But the optimistic value lives only in this component, and only while the Action runs. Another component that shows the same "like" doesn't see it. And nothing stops two quick clicks from sending two requests that finish in the wrong order.

In Kiss, the optimistic value goes in the state, so every component sees it. OptimisticSync is made for values like a "like": every click changes the state right away, but only one request at a time goes to the server, with the latest value. For commands like "add a todo", OptimisticCommand changes the state, sends the command, puts the old value back if it fails, and can reload from the server:

Kiss
class AddTodo extends OptimisticCommand<State, Todo[]> {
constructor(readonly todo: Todo) { super(); }

optimisticValue() { return [...this.state.todos, this.todo]; }
getValueFromState(state: State) { return state.todos; }
applyValueToState(state: State, todos: Todo[]) { return state.copy({ todos }); }
sendCommandToServer() { return this.deps.api.addTodo(this.todo); }
reloadFromServer() { return this.deps.api.loadTodos(); }
}

See Optimistic features.

Derived values​

In React, a value computed from the state is computed in the component, usually with useMemo. Each component that needs it computes it again, or you move it into a custom hook:

React
function DoneCount() {
const todos = useTodos();
const doneCount = useMemo(() => todos.filter((todo) => todo.done).length, [todos]);
return <p>{doneCount} done</p>;
}

In Kiss, put the logic in the state class, as a getter. Components and actions use the same getter. Because the state is immutable, the getter can cache its result inside the object, and the cached value can never go stale:

Kiss
class State {
constructor(readonly todos: readonly Todo[]) {}

private _doneCount?: number;

get doneCount() {
return (this._doneCount ??= this.todos.filter((todo) => todo.done).length);
}
}

function DoneCount() {
const doneCount = useSelect((state: State) => state.doneCount);
return <p>{doneCount} done</p>;
}

For values that depend on several parts of the state, a Selector class works the same way. See Business logic, and the comparison with Zustand, which shows all three ways.

Retry, debounce, fresh data, and more​

React has no opinion about these. Debouncing a search is a setTimeout in an effect, cleared in the cleanup. Retrying is a loop with a try. Not reloading data that is still fresh is a timestamp in a ref. Not running the same request twice is a flag. Each one is written by hand, in the component that needs it:

React
useEffect(() => {
let ignore = false;
const timeout = setTimeout(() => {
api.search(text).then((results) => { if (!ignore) setResults(results); });
}, 300);
return () => { ignore = true; clearTimeout(timeout); };
}, [text]);

In Kiss these are action features. You declare them on the action, and the logic is done for you:

Kiss
class LoadTodos extends Action {
fresh = 60000; // Don't reload for 60 seconds. The data is still fresh.
nonReentrant = true; // If it's already running, don't start it again.
retry = { on: true }; // If it fails, try again.
checkInternet = true; // If there is no internet, fail at once with a clear message.

async reduce() { ... }
}

class SearchTodos extends Action {
constructor(readonly text: string) { super(); }
debounce = 300; // Wait until the user stops typing.
ignoreOld = true; // A newer search cancels the older one.
async reduce() { ... }
}

The comparison with TanStack Query goes through all these features in detail, including polling, and paged loading.

Code outside components​

With Context, the state lives inside a provider component, and only components below it can read it or change it. But some code is not a component: a WebSocket handler that receives messages, an HTTP interceptor that must log the user out on a 401, a timer, a push notification. To reach the state from there, React apps save the dispatch function in a module variable, from inside the provider:

React
let dispatchFromOutside: Dispatch<AppAction> | null = null;

function AppProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(appReducer, initialState);
dispatchFromOutside = dispatch;
...
}

socket.on('message', (message) => dispatchFromOutside?.({ type: 'messageReceived', message }));

It works, but it's a workaround: the code outside can dispatch, but can't read the state, and nothing works before the provider renders.

In Kiss, the store is an object you create, before React renders anything. Any code can read store.state and call store.dispatch:

Kiss
socket.on('message', (message) => store.dispatch(new ReceiveMessage(message)));

api.onUnauthorized(() => store.dispatch(new LogOut()));

Long-lived things, like the socket itself, go in the store props, so that an action can open them, another can close them, and store.dispose() closes all of them at once. See Store props.

Dependencies​

Context is React's way to inject dependencies, and it does this well. You put the API client in a context, and components read it with useContext. In a test, you wrap the component in a provider with a fake client:

React
const ApiContext = createContext<Api>(new Api());

<ApiContext value={new FakeApi()}>
<TodoList />
</ApiContext>

The limit is that only components can read it. A reducer can't, and code outside React can't.

In Kiss, you give the dependencies to the store, and a base action class gives you typed access to them. Every action extends that base class:

Kiss
const store = createStore<State>({
initialState: State.initialState,
dependencies: () => ({ api: new Api() }),
});

abstract class Action extends KissAction<State> {
get deps(): Dependencies { return this.store.dependencies as Dependencies; }
}

In a test, give the store fake dependencies instead. See Dependency injection.

Persistence​

In React, saving the state to local storage is done by hand. A lazy initial state reads it, and an effect writes it on every change:

React
function TodosProvider({ children }: { children: ReactNode }) {
const [todos, dispatch] = useReducer(todosReducer, null, () =>
JSON.parse(localStorage.getItem('todos') ?? '[]'),
);

useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos));
}, [todos]);
...
}

This is short, but each provider does it on its own. It goes through JSON, so class instances come back as plain objects. It writes on every change, with no throttling. It breaks on the server, where there is no localStorage. And it only works with storage that answers right away. With storage that is async, like AsyncStorage in React Native, the first render can't wait for it, so you need a "not loaded yet" state, and code to hide the app until it's loaded.

Kiss persists the state with a persistor, which saves the state as it changes (at most once every 2 seconds, by default), and loads it when the app starts. It works with async storage, and with class instances. useIsStoreReady() tells you when the saved state has loaded:

Kiss
const store = createStore<State>({
initialState: State.initialState,
persistor: new ClassPersistor(...),
});

const isReady = useIsStoreReady(); // In components.

Testing​

A useReducer reducer is a pure function, so it's easy to test: call it with a state and an action, and check the result. That's a strength of the React pattern.

But the async logic is not in the reducer. It's in effects, event handlers, and Actions, inside components and custom hooks. To test it, you render it, with React Testing Library. A custom hook is rendered with renderHook, wrapped in its providers, with the API mocked by module name:

React
vi.mock('./api');

test('loads the todos', async () => {
vi.mocked(api.loadTodos).mockResolvedValue([todo1, todo2]);

const { result } = renderHook(() => useLoadTodos(), { wrapper: TodosProvider });

await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.todos).toEqual([todo1, todo2]);
});

In Kiss, the async logic is in actions, and actions don't need components. Each test creates its own store, with any initial state and fake dependencies. Then it dispatches the action, waits for it, and checks the state. No rendering, and no module to mock:

Kiss
test('loads the todos', async () => {
const store = new Store<State>({
initialState: State.initialState,
dependencies: () => ({ api: new FakeApi([todo1, todo2]) }),
});

await store.dispatchAndWait(new LoadTodos());

expect(store.state.todos).toEqual([todo1, todo2]);
expect(store.isFailed(LoadTodos)).toBe(false);
store.dispose();
});

Kiss also has many features to help you test, like waiting for a state condition, mocking actions, and recording all actions and state changes.

Why this matters​

For a small app, or for state that belongs to one component, plain React is enough, and you should use it. The cost shows up when the state is shared, and the app grows:

  • You build the store yourself. A context, a provider, a reducer, and custom hooks for each feature, nested in the right order. With Kiss, the store, the provider, and the hooks are there, and each feature is a field and a few actions.
  • The async logic lives in components. The reducer can't await, so requests, loading flags, errors, and race conditions are handled in effects and Actions, one component at a time. In Kiss, an action can be async, and any component can ask if it's running or failed.
  • Context re-renders too much. Without selectors, every reader of a context re-renders on every change, until you split it, memoize it, or add a library. In Kiss, useSelect selects.
  • The state is stuck inside the tree. Code outside React can't reach it, and tests must render it. In Kiss, the store is an object: actions, plain code, and tests all use it directly.

Give it a try and follow the tutorial.