SSE Hooks

useHistoryState

GitHub
Custom hook for managing state history, providing undo, redo, and clear functionality.

Installation

npx sse-hooks add use-history-state

Usage

import * as React from "react";
import Form from "./Form";
import { useHistoryState } from "@/hooks/useHistoryState";

export default function App() {
  const { state, set, undo, redo, clear, canUndo, canRedo } = useHistoryState({
    items: [],
  });

  const addTodo = (val) => {
    set({
      ...state,
      items: state.items.concat({ id: crypto.randomUUID(), name: val }),
    });
  };

  const removeTodo = (id) => {
    set({
      ...state,
      items: state.items.filter((item) => item.id !== id),
    });
  };

  return (
    <section>
      <header>
        <h1>useHistoryState</h1>
        <div>
          <button disabled={!canUndo} className="link" onClick={undo}>
            Undo
          </button>
          <button disabled={!canRedo} className="link" onClick={redo}>
            Redo
          </button>

          <button
            disabled={!state.items.length}
            className="link"
            onClick={clear}
          >
            Clear
          </button>
        </div>
        <Form addItem={addTodo} />
      </header>

      <ul>
        {state.items.map((item, index) => {
          return (
            <li key={index}>
              <span>{item.name}</span>
              <button className="link" onClick={() => removeTodo(item.id)}>
                Delete
              </button>
            </li>
          );
        })}
      </ul>
    </section>
  );
}
E;

API

Parameters

Parameter Default Type
options

{}

UseKeyOptions

  • Global configuration options.
keyMap-

KeyMap

  • An object defining the key bindings and their actions.

Returns

Return Value Default Type
bindings-

{ keys: string; category: string; description: string; }[]

Types Aliases

KeyMap

type
type KeyMap = Record<string, KeyHandler | KeyConfig>;
type KeyHandler = (event: React.KeyboardEvent) => void;

KeyConfig

interface
PropertyType
action

KeyHandler

The function to call

description?

string

Description for UI (e.g. "Save File")

category?

string

Category for UI (e.g. "Navigation")

preventDefault?

boolean

Prevent default browser behavior

stopPropagation?

boolean

Stop event propagation

once?

boolean

Only fire this hotkey once, then disable it

allowInInputs?

boolean

Allow this hotkey inside inputs/textareas

Code

import { useEffect, useRef, useState, KeyboardEvent, useMemo } from "react";
import React from "react";
import { useIsomorphicLayoutEffect } from "./use-isomorphic-layout-effect";

export type KeyHandler = (event: React.KeyboardEvent) => void;
export type KeyConfig = {
  /** The function to call */
  action: KeyHandler;
  /** Description for UI (e.g. "Save File") */
  description?: string;
  /** Category for UI (e.g. "Navigation") */
  category?: string;
  /** Prevent default browser behavior */
  preventDefault?: boolean;
  /** Stop event propagation */
  stopPropagation?: boolean;
  /** Only fire this hotkey once, then disable it */
  once?: boolean;
  /** Allow this hotkey inside inputs/textareas */
  allowInInputs?: boolean;
};

export type KeyMap = Record<string, KeyHandler | KeyConfig>;
export type UseKeyOptions = {
  /** DOM target to listen on (default: window) */
  target?: React.RefObject<HTMLElement> | null;
  /** Event type (default: keydown) */
  event?: "keydown" | "keyup";
  /** Global toggle to disable all hooks */
  enabled?: boolean;
  /** If true, logs key presses to console */
  debug?: boolean;
  /** Time in ms to wait for a sequence (e.g. "g g") */
  sequenceTimeout?: number;
  /** Custom filter: return false to skip the event */
  filter?: (e: React.KeyboardEvent) => boolean;
};

export const KEY_ALIASES: Record<string, string> = {
  ctrl: "control",
  opt: "alt",
  option: "alt",
  cmd: "meta",
  command: "meta",
  esc: "escape",
  space: " ",
  " ": "space",
};

/**
 * Normalizes a key event or string into a standard format:
 * "ctrl+alt+shift+key" (modifiers sorted alphabetically).
 */
export function normalizeKeyCombo(e: React.KeyboardEvent | string): string {
  if (typeof e === "string") {
    // Handle sequences like "g g" -> recursive normalization is tricky,
    // so we assume the user config passes individual chords like "ctrl+k"
    const parts = e
      .toLowerCase()
      .split("+")
      .map((p) => p.trim());
    const key = parts.pop();
    const mods = parts.map((m) => KEY_ALIASES[m] || m).sort();
    return [...mods, key === " " ? "space" : key].join("+");
  }

  const mods = [];
  if (e.ctrlKey) mods.push("control");
  if (e.altKey) mods.push("alt");
  if (e.metaKey) mods.push("meta");
  if (e.shiftKey) mods.push("shift");

  mods.sort();
  let key = e.key.toLowerCase();
  if (key === " ") key = "space";

  if (["control", "alt", "meta", "shift"].includes(key)) {
    return key;
  }

  return [...mods, key].join("+");
}

export function isInputTarget(e: React.KeyboardEvent): boolean {
  const target = e.target as HTMLElement;
  return (
    target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
  );
}

/**
 * CORE HOOK: useKeyListener
 * Low-level hook to bind a single event listener with lifecycle management.
 */
export function useKeyListener(
  handler: (e: KeyboardEvent) => void,
  options: UseKeyOptions = {},
) {
  const { target, event = "keydown", enabled = true } = options;
  const handlerRef = useRef(handler);

  useIsomorphicLayoutEffect(() => {
    handlerRef.current = handler;
  }, []);

  useEffect(() => {
    if (!enabled) return;

    const node = target ? target.current : window;
    if (!node) return;

    const eventListener = (e: Event) =>
      handlerRef.current(e as unknown as KeyboardEvent);
    node.addEventListener(event, eventListener);
    return () => node.removeEventListener(event, eventListener);
  }, [target, event, enabled]);
}

/**
 * A powerful sensor hook for handling keyboard shortcuts, sequences, and modifiers.
 *
 * It supports complex key combinations (`Ctrl+Shift+S`), Gmail-style sequences (`g then i`),
 * and provides metadata for generating "Keyboard Shortcut" UI help modals.
 *
 * @category sensors
 * @param {KeyMap} keyMap - An object defining the key bindings and their actions.
 * @param {UseKeyOptions} [options] - Global configuration options.
 * @returns {{ bindings: Array<{ keys: string, category: string, description: string }> }} - Metadata about the registered bindings for UI display.
 *
 * @throws Will log a warning in debug mode if a key combination is invalid.
 * @see [Documentation](https://sse-hooks.vercel.app/docs/hooks/use-key)
 * @public
 */
export function useKey(keyMap: KeyMap, options: UseKeyOptions = {}) {
  const { debug = false, sequenceTimeout = 1000, filter } = options;
  const [buffer, setBuffer] = useState<string[]>([]);
  const timeoutRef = useRef<NodeJS.Timeout | null>(null);
  const firedRef = useRef<Set<string>>(new Set());
  const callbackMapRef = useRef(keyMap);

  useIsomorphicLayoutEffect(() => {
    callbackMapRef.current = keyMap;
  });

  const handleEvent = (e: React.KeyboardEvent) => {
    // 1. Normalize
    const currentCombo = normalizeKeyCombo(e);

    if (debug) {
      console.log(
        `[useKey] Pressed: ${e.key} | Normalized: ${currentCombo} | Buffer: ${buffer.join(" ")}`,
      );
    }

    if (filter && !filter(e)) return;

    const nextBuffer = [...buffer, currentCombo];
    if (timeoutRef.current) clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(() => setBuffer([]), sequenceTimeout);
    setBuffer(nextBuffer);

    const sequenceKey = nextBuffer.join(" ");
    Object.entries(callbackMapRef.current).forEach(([keyBinding, value]) => {
      // Normalize user's binding (e.g. "Ctrl+Shift+S" -> "control+shift+s")
      const normalizedBinding = keyBinding
        .split(" ")
        .map(normalizeKeyCombo)
        .join(" ");

      const config: KeyConfig =
        typeof value === "function" ? { action: value } : value;

      let isMatch = false;
      if (normalizedBinding === sequenceKey) {
        isMatch = true;
        setBuffer([]);
      } else if (
        normalizedBinding === currentCombo &&
        nextBuffer.length === 1
      ) {
        isMatch = true;
      }

      if (isMatch) {
        // --- Checks ---
        if (config.once && firedRef.current.has(normalizedBinding)) return;
        if (!config.allowInInputs && isInputTarget(e)) return;

        // --- Execute ---
        if (config.preventDefault) e.preventDefault();
        if (config.stopPropagation) e.stopPropagation();
        if (config.once) firedRef.current.add(normalizedBinding);

        if (debug) console.log(`[useKey] Triggered: ${keyBinding}`);

        config.action(e);
      }
    });
  };

  useKeyListener(handleEvent, options);

  const bindings = useMemo(() => {
    return Object.entries(keyMap).map(([keys, value]) => {
      const config = typeof value === "function" ? ({} as KeyConfig) : value;
      return {
        keys,
        category: config.category || "General",
        description: config.description || "No description",
      };
    });
  }, [keyMap]);

  return { bindings };
}

Changelog

No recent changes