The code
The store has the data and four actions. The two counts are selector hooks: each computes once per change and every component that uses them shares the result.
import { createGlobalState } from 'react-global-state-hooks';
export interface Task {
id: number;
text: string;
done: boolean;
}
const initialTasks = (): Task[] => [
{ id: 1, text: 'Write the docs', done: true },
{ id: 2, text: 'Ship the examples', done: false },
{ id: 3, text: 'Review feedback', done: false },
];
const seed = () => ({ tasks: initialTasks(), nextId: 4 });
export const useTasks = createGlobalState(seed, {
name: 'tasks',
actions: {
restore() {
return ({ setState }) => setState(seed());
},
add(text: string) {
return ({ setState }) => {
const trimmed = text.trim();
if (!trimmed) return;
setState((state) => ({
tasks: [...state.tasks, { id: state.nextId, text: trimmed, done: false }],
nextId: state.nextId + 1,
}));
};
},
toggle(id: number) {
return ({ setState }) => {
setState((state) => ({
...state,
tasks: state.tasks.map((task) => (task.id === id ? { ...task, done: !task.done } : task)),
}));
};
},
remove(id: number) {
return ({ setState }) => {
setState((state) => ({ ...state, tasks: state.tasks.filter((task) => task.id !== id) }));
};
},
clearDone() {
return ({ setState }) => {
setState((state) => ({ ...state, tasks: state.tasks.filter((task) => !task.done) }));
};
},
},
});
// Derived values are computed once per change and shared by every component that uses them.
export const useOpenCount = useTasks.createSelectorHook((state) => state.tasks.filter((task) => !task.done).length, {
name: 'openCount',
});
export const useDoneCount = useTasks.createSelectorHook((state) => state.tasks.filter((task) => task.done).length, {
name: 'doneCount',
});The list filters with a value that is not in the store. isEqual: shallowCompare keeps the filtered array from
looking new when its items did not change, and memo on the row stops unchanged rows from rendering.
import { memo, useId, useState } from 'react';
import { shallowCompare } from 'react-global-state-hooks';
import { RenderCount } from '../shared/RenderCount';
import { useOpenCount, useTasks, type Task } from './store';
type Filter = 'all' | 'open' | 'done';
const filters: Filter[] = ['all', 'open', 'done'];
const empty: Record<Filter, [string, string]> = {
all: ['A fresh start.', 'Add a task above to keep going.'],
open: ['All clear.', 'Add a task above to keep going.'],
done: ['Nothing completed yet.', 'Complete a task to see it here.'],
};
const TaskRow = memo(function TaskRow({ task }: { task: Task }) {
return (
<li className="task-row">
<label>
<input type="checkbox" checked={task.done} onChange={() => useTasks.actions.toggle(task.id)} />
<span className={task.done ? 'task-row__text task-row__text--done' : 'task-row__text'}>{task.text}</span>
</label>
<RenderCount />
<button type="button" aria-label={`Remove ${task.text}`} onClick={() => useTasks.actions.remove(task.id)}>
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true" style={{ width: 14, height: 14 }}>
<path d="m6 6 12 12M18 6 6 18" />
</svg>
</button>
</li>
);
});
export function TaskList() {
// The filter is plain component state, not store state. The selector depends on it.
const [filter, setFilter] = useState<Filter>('all');
const group = useId();
const open = useOpenCount();
const [visible] = useTasks(
(state) => state.tasks.filter((task) => filter === 'all' || task.done === (filter === 'done')),
{ dependencies: [filter], isEqual: shallowCompare },
);
return (
<section className="demo-card" aria-label="Task list">
<div className="tasks-header tasks-header--list">
<RenderCount />
<fieldset className="segmented">
<legend>Show</legend>
{filters.map((option) => (
<label key={option}>
<input type="radio" name={group} checked={filter === option} onChange={() => setFilter(option)} />
<span>{option}</span>
</label>
))}
</fieldset>
<span className="tasks-open">{open} open</span>
</div>
{visible.length > 0 ? (
<ul className="task-list">
{visible.map((task) => (
<TaskRow key={task.id} task={task} />
))}
</ul>
) : (
<div className="task-empty">
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true" style={{ width: 27, height: 27 }}>
<path d="m5 12 4 4L19 6" />
</svg>
<h4>{empty[filter][0]}</h4>
<p className="task-empty-text">{empty[filter][1]}</p>
</div>
)}
</section>
);
}import { RenderCount } from '../shared/RenderCount';
import { useDoneCount, useOpenCount, useTasks } from './store';
export function TaskStats() {
// Selector hooks return the value. They change only when the count does.
const open = useOpenCount();
const done = useDoneCount();
return (
<section className="demo-card" aria-label="Task stats">
<div className="tasks-footer">
<span>
{open} open, {done} done
</span>
<RenderCount />
<button type="button" className="demo-text-button" onClick={() => useTasks.actions.clearDone()} disabled={done === 0}>
Clear done
</button>
</div>
</section>
);
}