3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お世話になっているReactカスタムフックたち

Posted at

前書き

↑上記のコードを元にTypescriptへ書き換えてたものになります。
もし、Javascriptで書きたい場合は自分のコードは読まず上記リポジトリを読んでください。
MITライセンスになっております。

今回のカスタムフックはSSR環境では未確認のためSSR環境で使用する場合は自己責任でお願い致します。

コードを書きたくない方はreact-useなるライブラリが存在しますのでこちらを使用してください。
プロジェクトの性質上、簡単にライブラリをインストール出来ない場合は今回のコードが便利です。

コード

useEffectOnce

マウント時・アンマウント時に一度だけ実行されます。(Strictモード時は2度実行されます)
引数を持たないuseEffectを書くとコードが読みづらくなってしまうので便利なカスタムフックです。

useEffectOnce.tsx
import { useEffect } from "react";

type UseEffectOnce = (cb: React.EffectCallback) => void;

export const useEffectOnce: UseEffectOnce = (cb) => {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(cb, []);
};
import { FC, useState } from "react";
import { useEffectOnce } from "../hooks/useEffectOnce";

const UseEffectOnce: FC = () => {
  const [count, setCount] = useState<number>(0);
  useEffectOnce(() => {
    console.log("マウント");
    return () => {
      console.log("アンマウント");
    };
  });

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
    </div>
  );
};

export default UseEffectOnce;
テストコード
useEffectOnce.test.tsx
import { renderHook } from "@testing-library/react";
import { useEffectOnce } from "../useEffectOnce";

describe("useEffectOnce", () => {
  it("should only call the callback once on mount", () => {
    const callback = vi.fn();

    renderHook(() => useEffectOnce(callback));

    expect(callback).toHaveBeenCalledTimes(1);
  });

  it("should not call the callback on re-render", () => {
    const callback = vi.fn();

    const { rerender } = renderHook(() => useEffectOnce(callback));

    rerender();

    expect(callback).toHaveBeenCalledTimes(1);
  });

  it("should not call the callback on unmount", () => {
    const callback = vi.fn();

    const { unmount } = renderHook(() => useEffectOnce(callback));

    unmount();

    expect(callback).toHaveBeenCalledTimes(1);
  });

useTimeout

タイムアウト処理を行うカスタムフックです。
単体で使用する場面は少なく今後記述するカスタムフックで使用する場面が多いカスタムフックです。

useTimeout.tsx
import { useCallback, useEffect, useRef } from "react";

type CallbackFunction = () => void;

type UseTimeoutReturn = {
  reset: () => void;
  clear: () => void;
};

type UseTimeout = (
  callback: CallbackFunction,
  delay: number | null
) => UseTimeoutReturn;

export const useTimeout: UseTimeout = (callback, delay) => {
  const callbackRef = useRef<CallbackFunction>(callback);
  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);
  const set = useCallback(() => {
    if (delay !== null) {
      timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
    }
  }, [delay]);
  const clear = useCallback(() => {
    if (timeoutRef.current !== null) {
      clearTimeout(timeoutRef.current);
    }
  }, []);
  useEffect(() => {
    set();
    return clear;
  }, [delay, set, clear]);
  const reset = useCallback(() => {
    clear();
    set();
  }, [clear, set]);

  return { reset, clear };
};
import { FC, useState } from "react";
import { useTimeout } from "../hooks/useTimeout";

const UseTimeout: FC = () => {
  const [count, setCount] = useState(10);
  const { clear, reset } = useTimeout(() => setCount((v) => v + 10), 1000);

  return (
    <div>
      <div>{count}</div>
      <button onClick={() => setCount((c) => c + 1)}>Increment</button>
      <button onClick={clear}>Clear Timeout</button>
      <button onClick={reset}>Reset Timeout</button>
    </div>
  );
};

export default UseTimeout;
テストコード
useTimeout.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useTimeout } from "../useTimeout";

describe("useTimeout", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.clearAllTimers();
  });

  it("should call the callback after the delay", () => {
    const callback = vi.fn();
    renderHook(() => useTimeout(callback, 1000));

    expect(callback).not.toHaveBeenCalled();

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    expect(callback).toHaveBeenCalledTimes(1);
  });

  it("should reset the timeout when reset is called", () => {
    const callback = vi.fn();
    const { result } = renderHook(() => useTimeout(callback, 1000));

    act(() => {
      result.current.reset();
    });

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    expect(callback).toHaveBeenCalledTimes(1);
  });

  it("should clear the timeout when clear is called", () => {
    const callback = vi.fn();
    const { result } = renderHook(() => useTimeout(callback, 1000));

    act(() => {
      result.current.clear();
    });

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    expect(callback).not.toHaveBeenCalled();
  });

  it("should not set a timeout when delay is null", () => {
    const callback = vi.fn();
    renderHook(() => useTimeout(callback, null));

    act(() => {
      vi.advanceTimersByTime(1000);
    });

    expect(callback).not.toHaveBeenCalled();
  });
});

useLongPress

DOMを一定間隔押し続けた場合に処理が実行されるカスタムフックです。
前述したuseEffectOnceuseTimeoutを使用しています。

useLongPress.tsx
import { MutableRefObject, useEffect } from "react";
import { useEffectOnce } from "./useEffectOnce";
import { useTimeout } from "./useTimeout";

type UseLongPressOptions = {
  delay?: number;
};

type UseLongPress = <E extends HTMLElement>(
  ref: MutableRefObject<E | null>,
  cb: () => void,
  { delay }: UseLongPressOptions
) => void;

export const useLongPress: UseLongPress = <E extends HTMLElement>(
  ref: MutableRefObject<E | null>,
  cb: () => void,
  { delay = 250 }: UseLongPressOptions = {}
) => {
  const { reset, clear } = useTimeout(cb, delay);

  useEffectOnce(clear);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const handleMouseDown = () => reset();
    const handleMouseUp = () => clear();
    const handleMouseLeave = () => clear();
    const handleTouchEnd = () => clear();

    element.addEventListener("mousedown", handleMouseDown);
    element.addEventListener("mouseup", handleMouseUp);
    element.addEventListener("mouseleave", handleMouseLeave);
    element.addEventListener("touchend", handleTouchEnd);

    return () => {
      element.removeEventListener("mousedown", handleMouseDown);
      element.removeEventListener("mouseup", handleMouseUp);
      element.removeEventListener("mouseleave", handleMouseLeave);
      element.removeEventListener("touchend", handleTouchEnd);
    };
  }, [ref, reset, clear]);
};
import { FC, useRef } from "react";
import { useLongPress } from "../hooks/useLongPress";

const UseLongPress: FC = () => {
  const elementRef = useRef<HTMLDivElement | null>(null);

  useLongPress(elementRef, () => alert("Long Press"), { delay: 500 });

  return (
    <div
      ref={elementRef}
      style={{
        backgroundColor: "red",
        width: "100px",
        height: "100px",
        position: "absolute",
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
        margin: "auto",
      }}
    />
  );
};

export default UseLongPress;
テストコード
useLongPress.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useEffectOnce } from "../useEffectOnce";
import { useLongPress } from "../useLongPress";
import { useTimeout } from "../useTimeout";

vi.mock("../useEffectOnce");
vi.mock("../useTimeout");

describe("useLongPress", () => {
  let ref: React.MutableRefObject<HTMLDivElement | null>;
  let cb: ReturnType<typeof vi.fn>; // ここを変更

  beforeEach(() => {
    ref = { current: document.createElement("div") };
    cb = vi.fn();
    (useTimeout as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
      reset: vi.fn(),
      clear: vi.fn(),
    });
    (useEffectOnce as unknown as ReturnType<typeof vi.fn>).mockImplementation(
      (cb) => cb()
    );
  });

  it("should trigger callback after long press", () => {
    renderHook(() => useLongPress(ref, cb, { delay: 500 }));

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mousedown"));
    });

    expect(
      useTimeout(() => console.log("reset"), 1000).reset
    ).toHaveBeenCalled();

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mouseup"));
    });

    expect(
      useTimeout(() => console.log("clear"), 1000).clear
    ).toHaveBeenCalled();
  });

  it("should clear timeout on mouse up or leave", () => {
    renderHook(() => useLongPress(ref, cb, { delay: 500 }));

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mousedown"));
      ref.current?.dispatchEvent(new MouseEvent("mouseup"));
    });

    expect(
      useTimeout(() => console.log("clear"), 0).clear
    ).toHaveBeenCalledTimes(2);

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mousedown"));
      ref.current?.dispatchEvent(new MouseEvent("mouseleave"));
    });

    expect(
      useTimeout(() => console.log("clear"), 0).clear
    ).toHaveBeenCalledTimes(3);
  });

  it("should clear timeout on touch end", () => {
    renderHook(() => useLongPress(ref, cb, { delay: 500 }));

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mousedown"));
      ref.current?.dispatchEvent(new TouchEvent("touchend"));
    });

    expect(useTimeout(() => console.log("clear"), 0).clear).toHaveBeenCalled();
  });
});

useArray

配列操作を行うカスタムフックです。
自分の中では一番便利なカスタムフックだと考えております。

useArray.tsx
import { useState } from "react";

type UseArrayReturn<T> = {
  array: T[];
  set: React.Dispatch<React.SetStateAction<T[]>>;
  push: (element: T) => void;
  filter: (callback: (value: T, index: number, array: T[]) => boolean) => void;
  update: (index: number, newElement: T) => void;
  remove: (index: number) => void;
  clear: () => void;
};

type UseArray = <T>(defaultValue: T[]) => UseArrayReturn<T>;

export const useArray: UseArray = <T,>(
  defaultValue: T[]
): UseArrayReturn<T> => {
  const [array, setArray] = useState<T[]>(defaultValue);

  const push = (element: T) => {
    setArray((a) => [...a, element]);
  };

  const filter = (
    callback: (value: T, index: number, array: T[]) => boolean
  ) => {
    setArray((a) => a.filter(callback));
  };

  const update = (index: number, newElement: T) => {
    setArray((a) => [
      ...a.slice(0, index),
      newElement,
      ...a.slice(index + 1, a.length),
    ]);
  };

  const remove = (index: number) => {
    setArray((a) => [...a.slice(0, index), ...a.slice(index + 1, a.length)]);
  };

  const clear = () => {
    setArray([]);
  };

  return { array, set: setArray, push, filter, update, remove, clear };
};
import { FC } from "react";
import { useArray } from "../hooks/useArray";

const UseArray: FC = () => {
  const {
    array,
    set,
    push,
    remove,
    filter,
    update,
    clear: arrayClear,
  } = useArray([1, 2, 3, 4, 5, 6]);

  return (
    <div>
      <div>{array.join(", ")}</div>
      <button onClick={() => push(7)}>7を追加</button>
      <button onClick={() => update(1, 9)}>2番目の要素を9に変える</button>
      <button onClick={() => remove(1)}>2番目の要素を削除</button>
      <button onClick={() => filter((n) => n < 3)}>
        要素で4未満の値を残す
      </button>
      <button onClick={() => set([1, 2])}>配列を[1,2]に変更</button>
      <button onClick={arrayClear}>クリア</button>
    </div>
  );
};

export default UseArray;
テストコード
useArray.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useArray } from "../useArray";

describe("useArray", () => {
  it("should initialize with default value", () => {
    const { result } = renderHook(() => useArray([1, 2, 3]));
    expect(result.current.array).toEqual([1, 2, 3]);
  });

  it("should add a new element using push", () => {
    const { result } = renderHook(() => useArray([1, 2, 3]));
    act(() => {
      result.current.push(4);
    });
    expect(result.current.array).toEqual([1, 2, 3, 4]);
  });

  it("should filter elements using filter", () => {
    const { result } = renderHook(() => useArray([1, 2, 3, 4]));
    act(() => {
      result.current.filter((n) => n % 2 === 0);
    });
    expect(result.current.array).toEqual([2, 4]);
  });

  it("should update element at specified index", () => {
    const { result } = renderHook(() => useArray([1, 2, 3]));
    act(() => {
      result.current.update(1, 5);
    });
    expect(result.current.array).toEqual([1, 5, 3]);
  });

  it("should remove element at specified index", () => {
    const { result } = renderHook(() => useArray([1, 2, 3]));
    act(() => {
      result.current.remove(1);
    });
    expect(result.current.array).toEqual([1, 3]);
  });

  it("should clear all elements", () => {
    const { result } = renderHook(() => useArray([1, 2, 3]));
    act(() => {
      result.current.clear();
    });
    expect(result.current.array).toEqual([]);
  });
});

useAsync

非同期処理を行うためのカスタムフックです。
非同期処理に必要な取得データ、ローディング、エラーを持たせているので読みやすいコードにしてくれます。

useAsync.tsx
import { DependencyList, useCallback, useEffect, useState } from "react";

type AsyncCallback<T> = () => Promise<T>;

export type UseAsyncReturn<T> = {
  loading: boolean;
  error: Error | undefined;
  value: T | undefined;
};

type UseAsync = <T>(
  callback: AsyncCallback<T>,
  dependencies?: DependencyList
) => UseAsyncReturn<T>;

export const useAsync: UseAsync = <T,>(
  callback: AsyncCallback<T>,
  dependencies: DependencyList = []
) => {
  const [loading, setLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | undefined>(undefined);
  const [value, setValue] = useState<T | undefined>(undefined);

  const callbackMemoized = useCallback(() => {
    setLoading(true);
    setError(undefined);
    setValue(undefined);
    callback()
      .then(setValue)
      .catch(setError)
      .finally(() => setLoading(false));
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, dependencies);

  useEffect(() => {
    callbackMemoized();
  }, [callbackMemoized]);

  return { loading, error, value };
};
import { FC } from "react";
import { useAsync } from "../hooks/useAsync";

const UseAsync: FC = () => {
  const { loading, error, value } = useAsync(() => {
    return new Promise((resolve, reject) => {
      const success = true;
      setTimeout(() => {
        success ? resolve("Hi") : reject("Error");
      }, 1000);
    });
  });

  return (
    <div>
      <div>Loading: {loading.toString()}</div>
      <div>{JSON.stringify(error)}</div>
      <div>{JSON.stringify(value)}</div>
    </div>
  );
};

export default UseAsync;
テストコード
useAsync.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useAsync } from "../useAsync";

describe("useAsync", () => {
  it("should return loading true while the async function is pending", async () => {
    const mockAsyncFunction = vi.fn().mockResolvedValue("Hello, World!");

    const { result } = renderHook(() => useAsync(mockAsyncFunction, []));

    expect(result.current.loading).toBe(true);

    await act(async () => {
      await mockAsyncFunction();
    });

    expect(result.current.loading).toBe(false);
    expect(result.current.value).toBe("Hello, World!");
    expect(result.current.error).toBeUndefined();
  });

  it("should handle async function errors correctly", async () => {
    const mockError = new Error("Something went wrong");
    const mockAsyncFunction = vi.fn().mockRejectedValue(mockError);

    const { result } = renderHook(() => useAsync(mockAsyncFunction, []));

    expect(result.current.loading).toBe(true);

    await act(async () => {
      try {
        await mockAsyncFunction();
      } catch { /* empty */ }
    });

    expect(result.current.loading).toBe(false);
    expect(result.current.error).toBe(mockError);
    expect(result.current.value).toBeUndefined();
  });
});

useEventListener

イベントリスナーを追加するカスタムフックです。
単体でも使用出来ますが、今後の記述するカスタムフックにも多分に使用されるほど柔軟性が高いです。

import { RefObject, useEffect, useRef } from "react";

type EventType = keyof WindowEventMap | "change";

type UseEventListener = <K extends EventType, E extends Event>(
  eventType: K,
  callback: (event: E) => void,
  element?:
    | Window
    | Document
    | HTMLElement
    | MediaQueryList
    | RefObject<HTMLElement | null>
) => void;

export const useEventListener: UseEventListener = <
  K extends EventType,
  E extends Event
>(
  eventType: K,
  callback: (event: E) => void,
  element:
    | Window
    | Document
    | HTMLElement
    | MediaQueryList
    | RefObject<HTMLElement | null> = window
) => {
  const callbackRef = useRef(callback);

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const targetElement:
      | HTMLElement
      | Window
      | Document
      | MediaQueryList
      | null = element && "current" in element ? element.current : element;
    if (!targetElement) return;

    const handler = (event: E) => callbackRef.current(event);

    targetElement.addEventListener(eventType, handler as EventListener);
    return () =>
      targetElement.removeEventListener(eventType, handler as EventListener);
  }, [eventType, element]);
};
import { FC, useState } from "react";
import { useEventListener } from "../hooks/useEventListener";

const UseEventListener: FC = () => {
  const [key, setKey] = useState("");
  useEventListener<"keydown", KeyboardEvent>("keydown", (e) => {
    setKey(e.key);
  });

  return <div>KeyDown Key: {key}</div>;
};

export default UseEventListener;
テストコード
useEventListener.test.tsx
import { renderHook } from "@testing-library/react";
import { useEventListener } from "../useEventListener";
import { fireEvent } from "@testing-library/dom";

describe("useEventListener", () => {
  it("should add event listener to the window by default", () => {
    const mockCallback = vi.fn();

    renderHook(() => useEventListener("resize", mockCallback));

    fireEvent(window, new Event("resize"));

    expect(mockCallback).toHaveBeenCalledTimes(1);
  });

  it("should add event listener to the provided element", () => {
    const mockCallback = vi.fn();
    const div = document.createElement("div");

    renderHook(() => useEventListener("click", mockCallback, div));

    fireEvent.click(div);

    expect(mockCallback).toHaveBeenCalledTimes(1);
  });

  it("should remove the event listener on cleanup", () => {
    const mockCallback = vi.fn();
    const div = document.createElement("div");

    const { unmount } = renderHook(() =>
      useEventListener("click", mockCallback, div)
    );

    fireEvent.click(div);

    expect(mockCallback).toHaveBeenCalledTimes(1);

    unmount();

    fireEvent.click(div);

    expect(mockCallback).toHaveBeenCalledTimes(1);
  });

  it("should handle ref objects as the target element", () => {
    const mockCallback = vi.fn();
    const div = document.createElement("div");

    renderHook(() => {
      const ref = { current: div };
      useEventListener("click", mockCallback, ref);
    });

    fireEvent.click(div);

    expect(mockCallback).toHaveBeenCalledTimes(1);
  });
});

useClickOutside

DOM要素の外側をクリックした際にFlagを下ろす操作を行うカスタムフックです。
モーダル処理を追加したい時にとても便利です。

useClickOutside.tsx
import { MutableRefObject } from "react";
import { useEventListener } from "./useEventListener";

type UseClickOutside = <E extends HTMLElement>(
  ref: MutableRefObject<E | null>,
  cb: (event: MouseEvent) => void
) => void;

export const useClickOutside: UseClickOutside = <E extends HTMLElement>(
  ref: MutableRefObject<E | null>,
  cb: (event: MouseEvent) => void
) => {
  useEventListener(
    "click",
    (e: MouseEvent) => {
      if (ref.current === null || ref.current.contains(e.target as Node))
        return;
      cb(e);
    },
    document
  );
};
import { useRef, useState } from "react";
import { useClickOutside } from "../hooks/useClickOutside";

const UseClickOutside = () => {
  const [isOpen, setIsOpen] = useState<boolean>(false);
  const modalRef = useRef<HTMLDivElement | null>(null);

  useClickOutside(modalRef, () => {
    if (isOpen) setIsOpen(false);
  });

  return (
    <div>
      <button
        onClick={(e) => {
          e.stopPropagation();
          setIsOpen(true);
        }}
      >
        Open
      </button>
      <div
        ref={modalRef}
        style={{
          display: isOpen ? "block" : "none",
          backgroundColor: "blue",
          color: "white",
          width: "100px",
          height: "100px",
          position: "absolute",
          top: "calc(50% - 50px)",
          left: "calc(50% - 50px)",
        }}
      >
        <span>Modal</span>
      </div>
    </div>
  );
};

export default UseClickOutside;
テストコード
useClickOutside.test.tsx
import { fireEvent, render } from "@testing-library/react";
import { useRef } from "react";
import { useClickOutside } from "../useClickOutside";

describe("useClickOutside", () => {
  it("should call the callback when clicking outside the element", () => {
    const callback = vi.fn();

    const TestComponent = () => {
      const ref = useRef<HTMLDivElement>(null);
      useClickOutside(ref, callback);
      return <div ref={ref}>Inside</div>;
    };

    render(<TestComponent />);

    fireEvent.click(document.body);

    expect(callback).toHaveBeenCalled();
  });

  it("should not call the callback when clicking inside the element", () => {
    const callback = vi.fn();

    const TestComponent = () => {
      const ref = useRef<HTMLDivElement>(null);
      useClickOutside(ref, callback);
      return <div ref={ref}>Inside</div>;
    };

    const { getByText } = render(<TestComponent />);

    fireEvent.click(getByText("Inside"));

    expect(callback).not.toHaveBeenCalled();
  });
});

useCopyToClipboard

クリップボードに保存するためのカスタムフックです。
カスタムフックで管理しないとクリップボード処理の追加が大変なため開発工数を削減にも繋がります。

useCopyToClipboard.tsx
import { useState } from "react";
import { useTimeout } from "./useTimeout";

type CopyToClipboardOptions = {
  debug?: boolean;
  message?: string;
};

type CopyType = (text: string, options?: CopyToClipboardOptions) => boolean;

type UseCopyToClipboardReturn = [
  (text: string, options?: CopyToClipboardOptions) => void,
  { value: string | undefined; success: boolean | undefined }
]

type UseCopyToClipboard = () =>  UseCopyToClipboardReturn;

const copy: CopyType = (text, options) => {
  try {
    navigator.clipboard.writeText(text);
    return true;
  } catch (error) {
    if (options?.debug) {
      console.error(options.message || "Copy failed", error);
    }
    return false;
  }
};

export const useCopyToClipboard: UseCopyToClipboard = () => {
  const [value, setValue] = useState<string>();
  const [success, setSuccess] = useState<boolean>();

  const { reset } = useTimeout(() => setSuccess(false), 3000);

  const copyToClipboard = (text: string, options?: CopyToClipboardOptions) => {
    const result = copy(text, options);
    if (result) setValue(text);
    setSuccess(result);
    reset();
  };

  return [copyToClipboard, { value, success }];
};
import { FC } from "react";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";

const UseCopyToClipboard: FC = () => {
  const [copyToClipboard, { success }] = useCopyToClipboard();
  return (
    <div>
      <button onClick={() => copyToClipboard("This was copied")}>
        {success ? "Copied" : "Copy Text"}
      </button>
      <input type="text" />
    </div>
  );
};

export default UseCopyToClipboard;
テストコード
useCopyToClipboard.test.tsx
import { act, renderHook } from "@testing-library/react";
import { vi } from "vitest";
import { useCopyToClipboard } from "../useCopyToClipboard";

describe("useCopyToClipboard", () => {
  beforeEach(() => {
    vi.useFakeTimers();
  });

  afterEach(() => {
    vi.clearAllTimers();
    vi.restoreAllMocks();
  });

  it("should copy text to clipboard and update state", async () => {
    const { result } = renderHook(() => useCopyToClipboard());

    const writeTextMock = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      value: {
        writeText: writeTextMock,
      },
      writable: true,
    });

    await act(async () => {
      result.current[0]("Hello, World!");
    });

    expect(writeTextMock).toHaveBeenCalledWith("Hello, World!");
    expect(result.current[1].value).toBe("Hello, World!");
    expect(result.current[1].success).toBe(true);
  });

  it("should handle copy failure and update state", async () => {
    const { result } = renderHook(() => useCopyToClipboard());

    const writeTextMock = vi.fn().mockRejectedValue(new Error("Copy failed"));
    Object.defineProperty(navigator, "clipboard", {
      value: {
        writeText: writeTextMock,
      },
      writable: true,
    });

    await act(async () => {
      result.current[0]("Hello, World!", { debug: true });
    });

    expect(result.current[1].value).toBe("Hello, World!");
    expect(result.current[1].success).toBe(true);
  });

  it("should reset success after timeout", async () => {
    const { result } = renderHook(() => useCopyToClipboard());

    const writeTextMock = vi.fn().mockResolvedValue(undefined);
    Object.defineProperty(navigator, "clipboard", {
      value: {
        writeText: writeTextMock,
      },
      writable: true,
    });

    await act(async () => {
      result.current[0]("Hello, World!");
    });

    expect(result.current[1].success).toBe(true);

    act(() => {
      vi.advanceTimersByTime(3000);
    });

    expect(result.current[1].success).toBe(false);
  });
});

useToggle

トグル処理を行うカスタムフックです。
多くの先駆者が作成しているカスタムフックでもあるため知っている方多いかと思います。

useToggle.tsx
import { MouseEvent, useState } from "react";

type ToggleValue = (
  value?: boolean | MouseEvent<HTMLButtonElement, globalThis.MouseEvent>
) => void;

type UseToggleReturn = [boolean, ToggleValue];

type UseToggle = (defaultValue: boolean) => UseToggleReturn;

export const useToggle: UseToggle = (defaultValue) => {
  const [value, setValue] = useState(defaultValue);
  const toggleValue: ToggleValue = (value) => {
    setValue((currentValue: boolean) =>
      typeof value === "boolean" ? value : !currentValue
    );
  };
  return [value, toggleValue];
};
import { FC } from "react";
import { useToggle } from "../hooks/useToggle";

const UseToggle: FC = () => {
  const [toggle, toggleValue] = useToggle(false);

  return (
    <div>
      <div>{toggle.toString()}</div>
      <button onClick={toggleValue}>Toggle</button>
      <button onClick={() => toggleValue(true)}>Make True</button>
      <button onClick={() => toggleValue(false)}>Make False</button>
    </div>
  );
};

export default UseToggle;
テストコード
useToggle.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useToggle } from "../useToggle";

describe("useToggle", () => {
  it("should initialize with the default value", () => {
    const { result } = renderHook(() => useToggle(true));
    const [value] = result.current;
    expect(value).toBe(true);
  });

  it("should toggle the value when called without an argument", () => {
    const { result } = renderHook(() => useToggle(false));
    const [, toggleValue] = result.current;

    act(() => {
      toggleValue(); // 切り替える
    });

    expect(result.current[0]).toBe(true);

    act(() => {
      toggleValue(); // 再度切り替える
    });

    expect(result.current[0]).toBe(false);
  });

  it("should set the value to the given boolean argument", () => {
    const { result } = renderHook(() => useToggle(false));
    const [, toggleValue] = result.current;

    act(() => {
      toggleValue(true);
    });

    expect(result.current[0]).toBe(true);

    act(() => {
      toggleValue(false);
    });

    expect(result.current[0]).toBe(false);
  });
});

useHover

ホバー処理を行うカスタムフックです。
シンプルなコードですが、よく使う処理のため重複して同じコードを書く心配がなくなります。

useHover.tsx
import { MutableRefObject, useState } from "react";
import { useEventListener } from "./useEventListener";

type UseHover = (ref: MutableRefObject<HTMLElement | null>) => boolean;

export const useHover: UseHover = (ref) => {
  const [hovered, setHovered] = useState(false);
  useEventListener("mouseover", () => setHovered(true), ref);
  useEventListener("mouseout", () => setHovered(false), ref);
  return hovered;
};
import { FC, useRef } from "react";
import { useHover } from "../hooks/useHover";

const UseHover: FC = () => {
  const hoverRef = useRef<HTMLDivElement | null>(null);
  const isHovered = useHover(hoverRef);

  return (
    <div
      ref={hoverRef}
      style={{
        backgroundColor: isHovered ? "blue" : "red",
        width: "100px",
        height: "100px",
        position: "absolute",
        top: 0,
        right: 0,
        bottom: 0,
        left: 0,
        margin: "auto",
      }}
    />
  );
};

export default UseHover;
テストコード
useHover.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useHover } from "../useHover";

describe("useHover", () => {
  it("should return false when not hovered", () => {
    const ref = { current: document.createElement("div") };
    const { result } = renderHook(() => useHover(ref));

    expect(result.current).toBe(false);
  });

  it("should return true when hovered", () => {
    const ref = { current: document.createElement("div") };
    const { result } = renderHook(() => useHover(ref));

    act(() => {
      ref.current?.dispatchEvent(
        new MouseEvent("mouseover", { bubbles: true })
      );
    });

    expect(result.current).toBe(true);
  });

  it("should return false when mouse leaves", () => {
    const ref = { current: document.createElement("div") };
    const { result } = renderHook(() => useHover(ref));

    act(() => {
      ref.current?.dispatchEvent(
        new MouseEvent("mouseover", { bubbles: true })
      );
    });

    expect(result.current).toBe(true);

    act(() => {
      ref.current?.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
    });

    expect(result.current).toBe(false);
  });
});

useOnScreen

DOM要素が画面上に表示されたかどうかを検知してくれるカスタムフックです。
多くのサイトで採用されているスクロールに合わせてDOM要素を表示する場面に便利です。

useOnScreen.tsx
import { MutableRefObject, useEffect, useState } from "react";

type UseOnScreen = (
  ref: MutableRefObject<HTMLElement | null>,
  rootMargin: string
) => boolean;

export const useOnScreen: UseOnScreen = (
  ref: MutableRefObject<HTMLElement | null>,
  rootMargin = "0px"
) => {
  const [isVisible, setIsVisible] = useState(false);
  useEffect(() => {
    if (ref.current == null) return;
    const observer = new IntersectionObserver(
      ([entry]) => setIsVisible(entry.isIntersecting),
      { rootMargin }
    );
    observer.observe(ref.current);
    return () => {
      if (ref.current == null) return;
      // eslint-disable-next-line react-hooks/exhaustive-deps
      observer.unobserve(ref.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ref.current, rootMargin]);
  return isVisible;
};
import { FC, useRef } from "react";
import { useOnScreen } from "../hooks/useOnScreen";

const UseOnScreen: FC = () => {
  const headerTwoRef = useRef<HTMLDivElement | null>(null);
  const visible = useOnScreen(headerTwoRef, "-100px");

  return (
    <div>
      <h1>Header</h1>
      <div>...</div>
      <h1 ref={headerTwoRef}>Header 2 {visible && "(Visible)"}</h1>
      <div>...</div>
    </div>
  );
};

export default UseOnScreen;
テストコード
useOnScreen.test.tsx
import { act, renderHook } from "@testing-library/react";
import { MutableRefObject } from "react";
import { useOnScreen } from "../useOnScreen";

class IntersectionObserverMock implements IntersectionObserver {
  root: Element | null = null;
  rootMargin: string = "";
  thresholds: ReadonlyArray<number> = [];
  observe: (target: Element) => void;
  unobserve: (target: Element) => void;
  disconnect: () => void;
  takeRecords: () => IntersectionObserverEntry[] = () => [];
  callback: IntersectionObserverCallback;

  constructor(callback: IntersectionObserverCallback) {
    this.callback = callback;
    this.observe = vi.fn();
    this.unobserve = vi.fn();
    this.disconnect = vi.fn();
  }

  trigger(entries: Partial<IntersectionObserverEntry>[]) {
    this.callback(entries as IntersectionObserverEntry[], this);
  }
}

describe("useOnScreen", () => {
  let observerMock: IntersectionObserverMock;
  let originalIntersectionObserver: typeof IntersectionObserver;

  beforeAll(() => {
    originalIntersectionObserver = globalThis.IntersectionObserver;
    globalThis.IntersectionObserver = vi.fn((callback) => {
      observerMock = new IntersectionObserverMock(callback);
      return observerMock;
    }) as unknown as typeof IntersectionObserver;
  });

  afterAll(() => {
    globalThis.IntersectionObserver = originalIntersectionObserver;
  });

  it("should return false if the element is not visible on the screen", () => {
    const ref: MutableRefObject<HTMLElement | null> = {
      current: document.createElement("div"),
    };
    const { result } = renderHook(() => useOnScreen(ref, "0px"));

    expect(result.current).toBe(false);
  });

  it("should return true when the element becomes visible", () => {
    const ref: MutableRefObject<HTMLElement | null> = {
      current: document.createElement("div"),
    };
    const { result } = renderHook(() => useOnScreen(ref, "0px"));

    act(() => {
      observerMock.trigger([{ isIntersecting: true }]);
    });

    expect(result.current).toBe(true);
  });

  it("should return false when the element becomes not visible", () => {
    const ref: MutableRefObject<HTMLElement | null> = {
      current: document.createElement("div"),
    };
    const { result } = renderHook(() => useOnScreen(ref, "0px"));

    act(() => {
      observerMock.trigger([{ isIntersecting: false }]);
    });

    expect(result.current).toBe(false);
  });
});

useOnlineStatus

サイトを利用しているユーザーがオンライン状態か判別するカスタムフックです。
オンライン状態・オフライン状態で機能を切り替える場面に便利です。

useOnlineStatus.tsx
import { useState } from "react";
import { useEventListener } from "./useEventListener";

type UseOnlineStatus = () => boolean;

export const useOnlineStatus: UseOnlineStatus = () => {
  const [online, setOnline] = useState(navigator.onLine);

  useEventListener("online", () => setOnline(navigator.onLine));
  useEventListener("offline", () => setOnline(navigator.onLine));

  return online;
};
import { FC } from "react";
import { useOnlineStatus } from "../hooks/useOnlineStatus";

const UseOnlineStatus: FC = () => {
  const online = useOnlineStatus();

  return <div>{online ? "online" : "offline"}</div>;
};

export default UseOnlineStatus;
テストコード
useOnlineStatus.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useOnlineStatus } from "../useOnlineStatus";

describe("useOnlineStatus", () => {
  beforeEach(() => {
    Object.defineProperty(globalThis.navigator, "onLine", {
      writable: true,
      value: true,
    });
  });

  it("should return true when online", () => {
    const { result } = renderHook(() => useOnlineStatus());
    expect(result.current).toBe(true);
  });

  it("should return false when offline", () => {
    Object.defineProperty(globalThis.navigator, "onLine", {
      writable: true,
      value: false,
    });

    const { result } = renderHook(() => useOnlineStatus());
    expect(result.current).toBe(false);
  });

  it("should update status to online when online event is triggered", () => {
    Object.defineProperty(globalThis.navigator, "onLine", {
      writable: true,
      value: false,
    });

    const { result } = renderHook(() => useOnlineStatus());

    act(() => {
      Object.defineProperty(globalThis.navigator, "onLine", {
        writable: true,
        value: true,
      });
      window.dispatchEvent(new Event("online"));
    });

    expect(result.current).toBe(true);
  });

  it("should update status to offline when offline event is triggered", () => {
    const { result } = renderHook(() => useOnlineStatus());

    act(() => {
      Object.defineProperty(globalThis.navigator, "onLine", {
        writable: true,
        value: false,
      });
      window.dispatchEvent(new Event("offline"));
    });

    expect(result.current).toBe(false);
  });
});

CleanShot 2024-08-31 at 11.16.01.gif

useFetch

fetch処理を気軽に使用出来るカスタムフックです。
カスタムし易い実装のため柔軟性が高いです。

useFetch.test.tsx
import { UseAsyncReturn, useAsync } from "./useAsync";

const DEFAULT_OPTIONS = {
  headers: { "Content-Type": "application/json" },
};

type UseFetch = <T>(
  url: string,
  options?: RequestInit,
  dependencies?: unknown[]
) => UseAsyncReturn<T>;

export const useFetch: UseFetch = (url, options = {}, dependencies = []) => {
  return useAsync(async () => {
    const res = await fetch(url, { ...DEFAULT_OPTIONS, ...options });
    if (res.ok) return res.json();
    const json = await res.json();
    return await Promise.reject(json);
  }, dependencies);
};
import { FC, useState } from "react";
import { useFetch } from "../hooks/useFetch";

const UseFetch: FC = () => {
  const [fetchId, setFetchId] = useState(1);
  const {
    loading: fetchLoading,
    error: fetchError,
    value: fetchValue,
  } = useFetch(`https://jsonplaceholder.typicode.com/todos/${fetchId}`, {}, [
    fetchId,
  ]);

  return (
    <div>
      <div>{fetchId}</div>
      <button onClick={() => setFetchId((currentId) => currentId + 1)}>
        Increment ID
      </button>
      <div>Loading: {fetchLoading.toString()}</div>
      <div>{JSON.stringify(fetchError, null, 2)}</div>
      <div>{JSON.stringify(fetchValue, null, 2)}</div>
    </div>
  );
};

export default UseFetch;
テストコード
useFetch.test.tsx
import { renderHook, waitFor } from "@testing-library/react";
import { useFetch } from "../useFetch";

describe("useFetch", () => {
  beforeEach(() => {
    vi.resetAllMocks();
  });

  it("fetches data successfully", async () => {
    const mockData = { message: "Success" };
    window.fetch = vi.fn().mockResolvedValue({
      ok: true,
      json: vi.fn().mockResolvedValue(mockData),
    });

    const { result } = renderHook(() =>
      useFetch("https://api.example.com/data")
    );

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.value).toEqual(mockData);
    expect(result.current.error).toBeUndefined();
    expect(window.fetch).toHaveBeenCalledTimes(1);
    expect(window.fetch).toHaveBeenCalledWith("https://api.example.com/data", {
      headers: { "Content-Type": "application/json" },
    });
  });

  it("handles fetch error correctly", async () => {
    const mockError = { error: "Something went wrong" };
    window.fetch = vi.fn().mockResolvedValue({
      ok: false,
      json: vi.fn().mockResolvedValue(mockError),
    });

    const { result } = renderHook(() =>
      useFetch("https://api.example.com/error")
    );

    expect(result.current.loading).toBe(true);

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.value).toBeUndefined();
    expect(result.current.error).toEqual(mockError);
    expect(window.fetch).toHaveBeenCalledTimes(1);
    expect(window.fetch).toHaveBeenCalledWith("https://api.example.com/error", {
      headers: { "Content-Type": "application/json" },
    });
  });
});

useStorage

ローカルストレージとセッションストレージを扱うカスタムフックです。
Utilsディレクトリでローカルストレージとセッションストレージの処理を書くよりも柔軟に扱えます。(ある時までは自分がそうでした)

useStorage.tsx
import {
  Dispatch,
  SetStateAction,
  useCallback,
  useEffect,
  useState,
} from "react";

type UseStorageReturn<T> = [T, Dispatch<SetStateAction<T>>, () => void];
type UseLocalStorage = <T>(key: string, defaultValue: T) => UseStorageReturn<T>;
type UseSessionStorage = <T>(
  key: string,
  defaultValue: T
) => UseStorageReturn<T>;
type UseStorage = <T>(
  key: string,
  defaultValue: T,
  storageObject: Storage
) => UseStorageReturn<T>;

export const useLocalStorage: UseLocalStorage = (key, defaultValue) => {
  return useStorage(key, defaultValue, window.localStorage);
};

export const useSessionStorage: UseSessionStorage = (key, defaultValue) => {
  return useStorage(key, defaultValue, window.sessionStorage);
};

const useStorage: UseStorage = <T,>(
  key: string,
  defaultValue: T,
  storageObject: Storage
): UseStorageReturn<T> => {
  const [value, setValue] = useState<T>(() => {
    const jsonValue = storageObject.getItem(key);
    if (jsonValue != null) return JSON.parse(jsonValue);
    if (typeof defaultValue === "function") {
      return (defaultValue as () => T)();
    } else {
      return defaultValue;
    }
  });

  useEffect(() => {
    if (value === undefined) return storageObject.removeItem(key);
    storageObject.setItem(key, JSON.stringify(value));
  }, [key, value, storageObject]);

  const remove = useCallback(() => {
    setValue(undefined as unknown as T);
  }, []);

  return [value, setValue, remove];
};
import { FC } from "react";
import { useLocalStorage, useSessionStorage } from "../hooks/useStorage";

const UseStorage: FC = () => {
  const [name, setName, removeName] = useSessionStorage("name", "roll1226");
  const [age, setAge, removeAge] = useLocalStorage("age", 25);

  return (
    <div>
      <div>
        {name} - {age}
      </div>
      <button onClick={() => setName("ROLL")}>名前をセット</button>
      <button onClick={() => setAge(30)}>年齢をセット</button>
      <button onClick={removeName}>名前を削除</button>
      <button onClick={removeAge}>年齢を削除</button>
    </div>
  );
};

export default UseStorage;
テストコード
useStorage.test.tsx
import { renderHook, act } from "@testing-library/react";
import { useLocalStorage, useSessionStorage } from "../useStorage";

describe("useLocalStorage", () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it("should use default value when localStorage is empty", () => {
    const { result } = renderHook(() => useLocalStorage("key", "default"));

    expect(result.current[0]).toBe("default");
  });

  it("should set and get value from localStorage", () => {
    const { result } = renderHook(() => useLocalStorage("key", "default"));

    act(() => {
      result.current[1]("newValue");
    });

    expect(result.current[0]).toBe("newValue");
    expect(localStorage.getItem("key")).toBe(JSON.stringify("newValue"));
  });

  it("should remove value from localStorage", () => {
    const { result } = renderHook(() => useLocalStorage("key", "default"));

    act(() => {
      result.current[1]("newValue");
      result.current[2]();
    });

    expect(result.current[0]).toBeUndefined();
    expect(localStorage.getItem("key")).toBeNull();
  });
});

describe("useSessionStorage", () => {
  beforeEach(() => {
    sessionStorage.clear();
  });

  it("should use default value when sessionStorage is empty", () => {
    const { result } = renderHook(() => useSessionStorage("key", "default"));

    expect(result.current[0]).toBe("default");
  });

  it("should set and get value from sessionStorage", () => {
    const { result } = renderHook(() => useSessionStorage("key", "default"));

    act(() => {
      result.current[1]("newValue");
    });

    expect(result.current[0]).toBe("newValue");
    expect(sessionStorage.getItem("key")).toBe(JSON.stringify("newValue"));
  });

  it("should remove value from sessionStorage", () => {
    const { result } = renderHook(() => useSessionStorage("key", "default"));

    act(() => {
      result.current[1]("newValue");
      result.current[2]();
    });

    expect(result.current[0]).toBeUndefined();
    expect(sessionStorage.getItem("key")).toBeNull();
  });
});

useStateWithHistory

自身で設定した値の変化を追跡・履歴として残してくれるしてくれるカスタムフックです。
面倒な履歴管理を簡単にしてくれます。

useStateWithHistory.tsx
import { useCallback, useRef, useState } from "react";

type Options = {
  capacity?: number;
};

type UseStateWithHistoryReturn<T> = [
  T,
  (value: T | ((prevValue: T) => T)) => void,
  {
    history: T[];
    pointer: number;
    back: () => void;
    forward: () => void;
    go: (index: number) => void;
  }
];

type UseStateWithHistory = <T>(
  defaultValue: T,
  { capacity }?: Options
) => UseStateWithHistoryReturn<T>;

export const useStateWithHistory: UseStateWithHistory = <T,>(
  defaultValue: T,
  { capacity = 10 } = {}
) => {
  const [value, setValue] = useState<T>(defaultValue);
  const historyRef = useRef<T[]>([value]);
  const pointerRef = useRef(0);

  const set = useCallback(
    (v: T | ((prevValue: T) => T)) => {
      const resolvedValue =
        typeof v === "function" ? (v as (prevValue: T) => T)(value) : v;
      if (historyRef.current[pointerRef.current] !== resolvedValue) {
        if (pointerRef.current < historyRef.current.length - 1) {
          historyRef.current.splice(pointerRef.current + 1);
        }
        historyRef.current.push(resolvedValue);
        while (historyRef.current.length > capacity) {
          historyRef.current.shift();
        }
        pointerRef.current = historyRef.current.length - 1;
      }
      setValue(resolvedValue);
    },
    [capacity, value]
  );

  const back = useCallback(() => {
    if (pointerRef.current <= 0) return;
    pointerRef.current--;
    setValue(historyRef.current[pointerRef.current]);
  }, []);

  const forward = useCallback(() => {
    if (pointerRef.current >= historyRef.current.length - 1) return;
    pointerRef.current++;
    setValue(historyRef.current[pointerRef.current]);
  }, []);

  const go = useCallback((index: number) => {
    if (index < 0 || index > historyRef.current.length - 1) return;
    pointerRef.current = index;
    setValue(historyRef.current[pointerRef.current]);
  }, []);

  return [
    value,
    set,
    {
      history: historyRef.current,
      pointer: pointerRef.current,
      back,
      forward,
      go,
    },
  ];
};
import { FC, useState } from "react";
import { useStateWithHistory } from "../hooks/useStateWithHistory";

const UseStateWithHistory: FC = () => {
  const [count, setCount, { history, pointer, back, forward, go }] =
    useStateWithHistory(1);
  const [name, setName] = useState("roll1226");

  return (
    <div>
      <div>{count}</div>
      <div>{history.join(", ")}</div>
      <div>Pointer - {pointer}</div>
      <div>{name}</div>
      <button onClick={() => setCount((currentCount) => currentCount * 2)}>
        x2
      </button>
      <button onClick={() => setCount((currentCount) => currentCount + 1)}>
        +1
      </button>
      <button onClick={back}>戻る</button>
      <button onClick={forward}>進む</button>
      <button onClick={() => go(2)}>インデックス2に移動</button>
      <button onClick={() => setName("John")}>再レンダリング(名前変更)</button>
    </div>
  );
};

export default UseStateWithHistory;
テストコード
useStateWithHistory.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useStateWithHistory } from "../useStateWithHistory";

describe("useStateWithHistory", () => {
  it("should initialize with default value", () => {
    const { result } = renderHook(() => useStateWithHistory("initial"));
    const [value] = result.current;
    expect(value).toBe("initial");
  });

  it("should add new values to history", () => {
    const { result } = renderHook(() => useStateWithHistory("initial"));
    const [, setValue] = result.current;

    act(() => {
      setValue("first");
    });

    act(() => {
      setValue("second");
    });

    const { history } = result.current[2];
    expect(history).toEqual(["initial", "first", "second"]);
  });

  it("should respect history capacity", () => {
    const { result } = renderHook(() =>
      useStateWithHistory("initial", { capacity: 2 })
    );
    const [, setValue] = result.current;

    act(() => {
      setValue("first");
      setValue("second");
      setValue("third");
    });

    const { history } = result.current[2];
    expect(history).toEqual(["second", "third"]);
  });

  it("should navigate back in history", () => {
    const { result } = renderHook(() => useStateWithHistory("initial"));
    const [, setValue] = result.current;

    act(() => {
      setValue("first");
      setValue("second");
    });

    const { back, history } = result.current[2];

    act(() => {
      back();
    });

    expect(result.current[0]).toBe("first");
    expect(history).toEqual(["initial", "first", "second"]);
  });

  it("should navigate forward in history", () => {
    const { result } = renderHook(() => useStateWithHistory("initial"));
    const [, setValue] = result.current;

    act(() => {
      setValue("first");
      setValue("second");
    });

    const { back, forward } = result.current[2];

    act(() => {
      back();
    });

    act(() => {
      forward();
    });

    expect(result.current[0]).toBe("second");
  });

  it("should go to a specific history index", () => {
    const { result } = renderHook(() => useStateWithHistory("initial"));
    const [, setValue] = result.current;

    act(() => {
      setValue("first");
      setValue("second");
      setValue("third");
    });

    const { go } = result.current[2];

    act(() => {
      go(0);
    });

    expect(result.current[0]).toBe("initial");

    act(() => {
      go(1);
    });

    expect(result.current[0]).toBe("first");

    act(() => {
      go(2);
    });

    expect(result.current[0]).toBe("second");
  });
});

useTranslation

登録されている単語の翻訳処理を行ってくれるカスタムフックです。
JSONを元に翻訳処理を行うよう実装しています。(DB管理の方が良いと思いますが)

translations/en_translations.json
{
  "hi": "Hello",
  "bye": "Goodbye",
  "nested": {
    "value": "This is a nested message"
  }
}
translations/ja_translations.json
{
  "hi": "こんにちは",
  "bye": "さようなら",
  "nested": {
    "value": "これはネストされたメッセージです"
  }
}
translations/index.ts
import en from "./en_translations.json";
import ja from "./ja_translations.json";

export { en, ja };
types/translations.d.ts
export interface Translations {
  hi: string;
  bye: string;
  nested: {
    value: string;
  };
}

declare module "*_translations.json" {
  const value: Translations;
  export default value;
}
useTranslation.tsx
import * as translations from "../translations";
import { Translations } from "../types/translations";
import { useLocalStorage } from "./useStorage";

type TranslateKeys<T> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${K}` | `${K}.${TranslateKeys<T[K]>}`
        : `${K}`;
    }[keyof T & string]
  : never;
type TranslateKey = TranslateKeys<Translations>;

type TranslationKeys = {
  [key in TranslateKey]: TranslationKeys | string;
};
type Language = keyof typeof translations;

type UseTranslationReturn = {
  language: Language;
  setLanguage: (language: Language) => void;
  fallbackLanguage: Language;
  setFallbackLanguage: (fallbackLanguage: Language) => void;
  t: (key: TranslateKey) => string | TranslationKeys | undefined;
};

type UseTranslation = () => UseTranslationReturn;

export const useTranslation: UseTranslation = () => {
  const [language, setLanguage] = useLocalStorage<Language>("language", "ja");
  const [fallbackLanguage, setFallbackLanguage] = useLocalStorage<Language>(
    "fallbackLanguage",
    "ja"
  );

  const translate = (key: TranslateKey) => {
    const keys = key.split(".") as TranslateKey[];
    return (
      getNestedTranslation(language, keys) ??
      getNestedTranslation(fallbackLanguage, keys) ??
      key
    );
  };

  return {
    language,
    setLanguage,
    fallbackLanguage,
    setFallbackLanguage,
    t: translate,
  };
};

const getNestedTranslation = (
  language: Language,
  keys: TranslateKey[]
): string | TranslationKeys | undefined => {
  const languageTranslations = translations[
    language
  ] as unknown as TranslationKeys;

  return keys.reduce<string | TranslationKeys | undefined>((obj, key) => {
    if (obj && typeof obj === "object") {
      return (obj as TranslationKeys)[key];
    }
    return undefined;
  }, languageTranslations);
};
import { FC } from "react";
import { useTranslation } from "../hooks/useTranslation";

const UseTranslation: FC = () => {
  const { language, setLanguage, setFallbackLanguage, t } = useTranslation();

  return (
    <div>
      <div>{language}</div>
      <div>{t("hi") as string}</div>
      <div>{t("bye") as string}</div>
      <div>{t("nested.value") as string}</div>
      <button onClick={() => setLanguage("ja")}>日本語に変更</button>
      <button onClick={() => setLanguage("en")}>英語に変更</button>
      <button onClick={() => setFallbackLanguage("ja")}>
        フォールバック言語(日本語)
      </button>
    </div>
  );
};

export default UseTranslation;
テストコード
useTranslation.test.tsx
import { act, renderHook } from "@testing-library/react";
import { useTranslation } from "../useTranslation";

describe("useTranslation", () => {
  it("should initialize with default language", () => {
    const { result } = renderHook(() => useTranslation());
    expect(result.current.language).toBe("ja");
  });

  it("should return the correct translation for a key", () => {
    const { result } = renderHook(() => useTranslation());
    const { t } = result.current;

    expect(t("hi")).toBe("こんにちは");
    expect(t("nested.value")).toBe("これはネストされたメッセージです");
  });

  it("should set language en", () => {
    const { result } = renderHook(() => useTranslation());
    const { setLanguage } = result.current;
    act(() => {
      setLanguage("en");
    });

    expect(result.current.language).toBe("en");
  });

  it("should fallback to the fallback language", () => {
    const { result } = renderHook(() => useTranslation());
    const { t, setLanguage, setFallbackLanguage } = result.current;

    act(() => {
      setLanguage("en");
      setFallbackLanguage("ja");
    });

    expect(t("hi")).toBe("Hello");
    expect(t("nested.value")).toBe("This is a nested message");
  });

  it("should update language correctly", () => {
    const { result } = renderHook(() => useTranslation());
    const { t, setLanguage } = result.current;

    act(() => {
      setLanguage("en");
    });

    expect(t("hi")).toBe("Hello");
    expect(t("nested.value")).toBe("This is a nested message");
  });
});

型定義を整えているため補完がしっかり効いてくれます。

CleanShot 2024-08-30 at 07.26.37.gif

まとめ

自分自身やプロジェクトで使いやすいカスタムフックを作成してより良い開発ライフを過ごしてください。

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?