前書き
↑上記のコードを元にTypescriptへ書き換えてたものになります。
もし、Javascriptで書きたい場合は自分のコードは読まず上記リポジトリを読んでください。
MITライセンスになっております。
今回のカスタムフックはSSR環境では未確認のためSSR環境で使用する場合は自己責任でお願い致します。
コードを書きたくない方はreact-use
なるライブラリが存在しますのでこちらを使用してください。
プロジェクトの性質上、簡単にライブラリをインストール出来ない場合は今回のコードが便利です。
コード
useEffectOnce
マウント時・アンマウント時に一度だけ実行されます。(Strictモード時は2度実行されます)
引数を持たないuseEffect
を書くとコードが読みづらくなってしまうので便利なカスタムフックです。
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;
テストコード
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
タイムアウト処理を行うカスタムフックです。
単体で使用する場面は少なく今後記述するカスタムフックで使用する場面が多いカスタムフックです。
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;
テストコード
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を一定間隔押し続けた場合に処理が実行されるカスタムフックです。
前述したuseEffectOnce
とuseTimeout
を使用しています。
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;
テストコード
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
配列操作を行うカスタムフックです。
自分の中では一番便利なカスタムフックだと考えております。
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;
テストコード
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
非同期処理を行うためのカスタムフックです。
非同期処理に必要な取得データ、ローディング、エラーを持たせているので読みやすいコードにしてくれます。
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;
テストコード
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;
テストコード
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を下ろす操作を行うカスタムフックです。
モーダル処理を追加したい時にとても便利です。
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;
テストコード
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
クリップボードに保存するためのカスタムフックです。
カスタムフックで管理しないとクリップボード処理の追加が大変なため開発工数を削減にも繋がります。
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;
テストコード
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
トグル処理を行うカスタムフックです。
多くの先駆者が作成しているカスタムフックでもあるため知っている方多いかと思います。
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;
テストコード
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
ホバー処理を行うカスタムフックです。
シンプルなコードですが、よく使う処理のため重複して同じコードを書く心配がなくなります。
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;
テストコード
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要素を表示する場面に便利です。
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;
テストコード
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
サイトを利用しているユーザーがオンライン状態か判別するカスタムフックです。
オンライン状態・オフライン状態で機能を切り替える場面に便利です。
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;
テストコード
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);
});
});
useFetch
fetch処理を気軽に使用出来るカスタムフックです。
カスタムし易い実装のため柔軟性が高いです。
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;
テストコード
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ディレクトリでローカルストレージとセッションストレージの処理を書くよりも柔軟に扱えます。(ある時までは自分がそうでした)
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;
テストコード
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
自身で設定した値の変化を追跡・履歴として残してくれるしてくれるカスタムフックです。
面倒な履歴管理を簡単にしてくれます。
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;
テストコード
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管理の方が良いと思いますが)
{
"hi": "Hello",
"bye": "Goodbye",
"nested": {
"value": "This is a nested message"
}
}
{
"hi": "こんにちは",
"bye": "さようなら",
"nested": {
"value": "これはネストされたメッセージです"
}
}
import en from "./en_translations.json";
import ja from "./ja_translations.json";
export { en, ja };
export interface Translations {
hi: string;
bye: string;
nested: {
value: string;
};
}
declare module "*_translations.json" {
const value: Translations;
export default value;
}
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;
テストコード
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");
});
});
型定義を整えているため補完がしっかり効いてくれます。
まとめ
自分自身やプロジェクトで使いやすいカスタムフックを作成してより良い開発ライフを過ごしてください。