Kiss State is a powerful and modern state management library for React, launched in May 2025. While new to React, Kiss has been available for Flutter for years, battle-tested in hundreds of real-world applications.
AI-ready: Centralizes state and business logic in predictable ways, making it easier for AI models to reason about code. This improves AI-driven code generation and helps you get good results with surprisingly little effort.
📄 Complete AI Documentation: A single file containing all Kiss documentation, optimized for AI agents. Just copy the link and paste it into any AI.
Store and state
The store holds all application state. Here are a few examples:
// If your state is a number
const store = createStore<number>({initialState: 1});
// If your state is a plain JavaScript object
const store = createStore({initialState: {name: 'Mary', age: 25}});
// If your state is an instance of an ES6 class
class State { constructor(public name: string, public age: number){} }
const store = createStore<State>({initialState: new State('Mary', 25)});
Use a StoreProvider to add the store to your component tree:
function App() {
return (
<StoreProvider store={store}>
<AppContent />
</StoreProvider>
);
};
Components use the state
The useAllState hook lets you access the state from any component.
The component re-renders when the state changes.
function MyComponent() {
const state = useAllState<State>();
return <div>{state.name} is {state.age} years old</div>;
};
The useSelect hook selects only the part of the state that your component needs.
The component re-renders only when that part changes.
function MyComponent() {
const name = useSelect((state: State) => state.name);
const age = useSelect((state: State) => state.age);
return <div>{name} is {age} years old</div>;
};
The useObject hook also causes the component to re-render only when needed:
function MyComponent() {
const state = useObject((state: State) => ({
name: state.name,
age: state.age,
}));
return <div>{state.name} is {state.age} years old</div>;
};
Actions and reducers
An action is a class with its own reducer function. This reducer has access to the current state and returns a new state.
class Increment extends Action {
reduce() {
// The reducer has access to the current state
return this.state + 1; // It returns a new state
};
}
Dispatch an action
The store's state is immutable.
The only way to change the store's state is by dispatching an action. This runs the action's reducer to get a new state. The new state replaces the current one, and affected components re-render.
// Dispatch an action
store.dispatch(new Increment());
// Dispatch multiple actions
store.dispatchAll([new Increment(), new LoadText()]);
// Dispatch an action and wait for it to finish
await store.dispatchAndWait(new Increment());
// Dispatch multiple actions and wait for them to finish
await store.dispatchAndWaitAll([new Increment(), new LoadText()]);
// Dispatch an action when the state meets a condition
store.dispatchWhen(new LoadText(), (state) => state.count >= 3, { timeoutMillis: 0 });
Components can dispatch actions
Hooks for dispatching actions include useDispatch, useDispatchAll, useDispatchWhen, etc.
function MyComponent() {
const dispatch = useDispatch();
return (
<Button onClick={() => dispatch(new LoadText())}>
Click me!
</Button>
);
};
You can also get the store with useStore and dispatch actions directly:
function MyComponent() {
const store = useStore();
return (
<Button onClick={() => store.dispatch(new LoadText())}>
Click me!
</Button>
);
};
To dispatch actions when the component mounts, when some values change, and when it unmounts:
const dispatch = useDispatch({
deps: userId,
onMount: (store) => store.dispatch(new LoadUser(userId)),
onDepsChange: (store, oldUserId) => {
store.dispatch(new StopListening(oldUserId));
store.dispatch(new LoadUser(userId));
},
onUnmount: (store) => store.dispatch(new CleanResources()),
});
Actions can do asynchronous work
Actions can fetch data from the internet or do any other async work.
const store = createStore<string>({initialState: ''});
class LoadText extends Action {
// This reducer returns a Promise
async reduce() {
// Download something from the internet
let response = await fetch('https://dummyjson.com/todos/1');
let text = await response.text();
// Change the state with the downloaded information
return (state: string) => text;
}
}
Actions can throw errors
If an error occurs, you can simply throw it. In this case, the action will not change the state. Errors are caught globally and can be handled later in one central place.
If you throw a UserException, a type provided by Kiss, a dialog or another UI element
opens automatically and shows the error message to the user.
class LoadText extends Action {
async reduce() {
let response = await fetch("https://dummyjson.com/todos/random/1");
if (!response.ok) throw new UserException("Failed to load.");
let text = await response.text();
return (state: string) => text;
}
}
Components can react to actions
To show a spinner while an asynchronous action is running, use useIsWaiting(ActionType).
To show an error message inside the component, use useIsFailed(ActionType).
function MyComponent() {
const isWaiting = useIsWaiting(LoadText);
const isFailed = useIsFailed(LoadText);
const state = useAllState<string>();
if (isWaiting) return <CircularProgress />
if (isFailed) return <p>Loading failed...</p>;
return <p>{state}</p>;
}
Actions can dispatch other actions
You can use dispatchAndWait to dispatch an action and wait for it to finish.
class LoadTextAndIncrement extends Action {
async reduce() {
// Dispatch and wait for the action to finish
await this.dispatchAndWait(new LoadText());
// Only then increment the state
return (state: State) => state.copy({ count: state.count + 1 });
}
}
You can also dispatch actions in parallel and wait for them to finish:
class BuyAndSell extends Action {
async reduce() {
// Dispatch and wait for both actions to finish
await this.dispatchAndWaitAll([
new BuyAction('IBM'),
new SellAction('TSLA')
]);
return (state: State) => state.copy({
message: `New cash balance is ${state.cash}`
});
}
}
You can also use waitCondition to wait until the state meets a condition:
class SellStockForPrice extends Action {
constructor(public stock: string, public price: number) { super(); }
async reduce() {
// Wait until the stock price is higher than the limit price
await this.waitCondition(
(state) => state.stocks.getPrice(this.stock) >= this.price,
{ timeoutMillis: 0 }, // No timeout.
);
// Only then post the sell order to the backend
let amount = await postSellOrder(this.stock);
return (state: State) =>
state.copy({
stocks: state.stocks.setAmount(this.stock, amount),
});
}
}
Add features to your actions
It's easy to add your own reusable features to actions. Kiss also includes several useful features out of the box:
NonReentrant
To prevent an action from running again while it is already running,
add the nonReentrant property to your action class and set it to true.
class LoadText extends Action {
nonReentrant = true;
reduce() { ... }
}
Retry
To retry an action a few times with exponential backoff if it fails,
add the retry property to your action class.
class LoadText extends Action {
retry = {on: true}
async reduce() { ... }
}
And you can specify the retry policy:
class LoadText extends Action {
retry = {
initialDelay: 350, // Delay in milliseconds before the first retry
maxRetries: 3, // Number of retries before stopping
multiplier: 2, // Factor used to increase the delay after each retry
maxDelay: 5000, // Maximum delay between retries, in milliseconds
}
async reduce() { ... }
}
CheckInternet
To check for an internet connection before running the action, add the checkInternet property.
If there is no internet connection, the action stops. You can also show a dialog that says,
"There is no internet connection. Please check your connection."
class LoadPrices extends Action {
checkInternet = { dialog: true }
async reduce() { ... }
}
UnlimitedRetryCheckInternet
To keep trying an action until it succeeds, even while the device is offline, add the
unlimitedRetryCheckInternet property. If there is no internet, the action waits, and
retries until there is. It also retries if there is internet but the action fails.
class LoadPrices extends Action {
unlimitedRetryCheckInternet = true
async reduce() { ... }
}
Debounce
To limit how often an action runs in response to rapid input, add a debounce property
to your action class. For example, when a user types into a search bar, debouncing ensures that not
every keystroke triggers a server request. Instead, the action waits until the user stops typing for a short time
before running.
class SearchText extends Action {
constructor(public searchTerm: string) { super(); }
debounce = 300 // Milliseconds
async reduce() {
let result = await loadJson('https://example.com/?q=', this.searchTerm);
return (state: State) => state.copy({searchResult: result});
}
}
IgnoreOld
When the user switches tabs or filters quickly, a slow older request may finish last, and
overwrite the newer information. To prevent that, add the ignoreOld property to your action
class. When the action is dispatched again, the previous ones that are still running are
ignored, and their requests are cancelled, if you pass them the abortSignal.
class LoadTab extends Action {
constructor(readonly tab: string) { super(); }
ignoreOld = true;
async reduce() {
let response = await fetch(`/api/items?tab=${this.tab}`, {signal: this.abortSignal});
let items = await response.json();
return (state: State) => state.copy({items});
}
}
Throttle
To prevent an action from running too frequently, you can add a throttle property to your
action class. The action then runs at most once per throttle period. If you dispatch it again
during that period, the new dispatch is aborted. After the period ends, the next dispatch runs,
and starts a new period.
class LoadPrices extends Action {
throttle = 5000 // Milliseconds
async reduce() {
let result = await loadJson('https://example.com/prices');
return (state: State) => state.copy({prices: result});
}
}
Fresh
To avoid reloading the same information too often, add a fresh property to your action class.
When the action runs, its result is considered fresh for that period, and dispatching it again
during that period is aborted. After the period ends, the data is stale, and the next dispatch
runs and starts a new period.
class LoadPrices extends Action {
fresh = 5000 // Milliseconds
async reduce() {
let result = await loadJson('https://example.com/prices');
return (state: State) => state.copy({prices: result});
}
}
Sequential
To make actions run one at a time, in the exact order they were dispatched, add the
sequential property to your action class and set it to true. Each dispatched action
waits until all the actions dispatched before it have finished, and only then runs.
class SaveItem extends Action {
sequential = true;
constructor(readonly item: Item) { super(); }
async reduce() {
await saveItem(this.item);
return null;
}
}
Polling
To periodically dispatch an action at a fixed interval, add a poll property to your action
class, and say which action each tick dispatches. Dispatch it with Poll.start to start
polling, and with Poll.stop to stop polling.
class LoadPrices extends Action {
constructor(readonly poll = Poll.once) { super(); }
pollInterval = 5000; // Milliseconds
createPollingAction() { return new LoadPrices(); }
async reduce() {
let result = await loadJson('https://example.com/prices');
return (state: State) => state.copy({prices: result});
}
}
dispatch(new LoadPrices(Poll.start)); // Start polling.
dispatch(new LoadPrices(Poll.stop)); // Stop polling.
OptimisticCommand
To provide instant feedback when an action sends a command to the server (like adding a todo,
or sending a message), extend OptimisticCommand. It changes the state immediately, before the server
confirms that the command succeeded. If the command fails, the state is changed back, and the
error is shown to the user. It can also reload the value from the server.
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 api.addTodo(this.todo); }
reloadFromServer() { return api.loadTodos(); }
}
OptimisticSync
For rapid toggles (like a "like" button), extend OptimisticSync. Every dispatch changes the
state immediately, but only one request per key is sent to the server at a time. When the
request finishes, if the state changed meanwhile, a follow-up request sends the latest value,
until the state stabilizes. This keeps the UI responsive, while minimizing server load.
class ToggleLike extends OptimisticSync<State, boolean> {
constructor(readonly itemId: string) { super(); }
optimisticSyncKeyParams() { return this.itemId; }
valueToApply() { return !this.state.isLiked(this.itemId); }
applyOptimisticValueToState(state: State, liked: boolean) { return state.setLiked(this.itemId, liked); }
getValueFromState(state: State) { return state.isLiked(this.itemId); }
sendValueToServer(liked: boolean) { return api.setLiked(this.itemId, liked); }
}
OptimisticSyncWithPush and ServerPush
If your app also receives server pushes (WebSockets, Server-Sent Events, Firebase) that may
change the same values, and more than one device can change them, extend
OptimisticSyncWithPush instead of OptimisticSync, and apply the pushes with an action that
extends ServerPush. They track revisions, so that stale or out-of-order pushes are ignored,
local changes are not overwritten by older pushes, and the last write wins across devices.
See all the features, and which ones can be combined, in Action features.
Persist the state
You can add a persistor to save the state to the local device disk.
It supports serialization of JavaScript objects and ES6 class instances.
const store = createStore<State>({
initialState: new State(),
persistor: new Persistor(),
});
// Wait for the saved state to load, then start the app.
await store.ready();
store.dispatch(new InitAppAction());
Testing your app is easy
Just dispatch actions and wait for them to finish. Then verify the new state or check whether an error occurred.
class State {
constructor(
public items: string[],
public selectedItem: number
) {}
}
test('Selecting an item', async () => {
const store = createStore<State>({
initialState: new State(['A', 'B', 'C'], -1)
});
// Should select item 2
await store.dispatchAndWait(new SelectItem(2));
expect(store.state.selectedItem).toBe('B');
// Fail to select item 42
let status = await store.dispatchAndWait(new SelectItem(42));
expect(status.originalError).toBeInstanceOf(UserException);
});
Advanced setup
If you are the team lead, you can set up the app's infrastructure in one central place, so developers can focus on business logic.
You can add a stateObserver to collect app metrics, an errorObserver to log errors,
an actionObserver to log information to the console during development,
and a globalWrapError to change errors before they are handled.
const store = createStore<string>({
stateObserver: (action, prevState, newState, error, count) => { ... },
errorObserver: (error, action, store) => { ... },
actionObserver: (action, count, ini) => { ... },
globalWrapError: (error) => { ... }
});
For example, here we handle FirestoreError errors thrown by Firebase.
We convert them into UserException errors, which are built-in types that
automatically show a message to the user in an error dialog:
globalWrapError: (error: any) => {
return (error instanceof FirestoreError)
? new UserException('Error connecting to Firebase')
: error;
}
By default, Kiss logs what it does (for example, each dispatched action) to the console.
Use logger to send these messages somewhere else, or set it to null to turn logging off.
With null, Kiss doesn't even build the log messages:
const store = createStore<State>({
initialState: new State(),
logger: (obj) => myLogger.info(obj), // Or `null` to turn logging off.
logStateChanges: true, // Also log every state change.
});
Advanced action configuration
The team lead can create a base action class that all actions will extend, and add some common functionality to it. For example, the base class can provide getter shortcuts to important parts of the state and helper methods to find information.
class State {
items: Item[];
selectedItem: Item;
}
export abstract class Action extends KissAction<State> {
// Convenience getters
get items() { return this.state.items; }
get selectedItem() { return this.state.selectedItem; }
// Selectors
findById(id: number) { return this.items.find((item) => item.id === id); }
get selectedIndex() { return this.items.indexOf(this.selectedItem); }
searchByText(text: string) { return this.items.find((item) => item.text.includes(text)); }
}
Now, all actions can use them to access the state in their reducers:
class SelectItem extends Action {
constructor(public id: number) { super(); }
reduce() {
let item = this.findById(this.id);
if (item === undefined) throw new Error('Item not found');
return this.state.copy({selectedItem: item});
}
}