Skip to content
react-global-state-hooks
Interactive example · Selectors

A task list that composes

Filter tasks, add an action, and share derived counts through reusable hooks. The filter lives outside the store.

Try it hereLive example
Application preview

Loading the example…

store.ts
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',
});
Ready. Make a change to begin.Reset returns this example to its initial state.
Take it with you

The filter is local. The tasks and reusable derivations are shared.

01

Toggle a task. The list and the stats render again, and so does only the row you toggled. The other rows and the add form keep their counts.

02

Type in the add form. Nothing else renders, because the form uses the actions and never subscribes to the tasks. A blank task is rejected beside the input.

03

Change the Show filter. The list re-selects without touching the store; the filter is component state passed to the selector through `dependencies`.

Read the full guide

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.

store.ts
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.

TaskList.tsx
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>
  );
}
TaskStats.tsx
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>
  );
}

Related documentation

Keep exploring

Another piece of the model.

↑↓ Navigate ↵ Open Esc CloseRGSH docs
Get started

Shared state. Precise subscriptions.
Built-in DevTools.