react-use の実装から学ぶ custom hooks
react-use とは
- React の便利 custom hooks が定義されているライブラリ。
- 頑張って実装した hooks が「react-use にすでにありますよ」と指摘されることがよくある。
記事の目的
- react-use を使う、使わないに関わらず react hooks の実装や設計から学べることは多い。
- いくつか react-use の実装例[1]を見ながら、 筆者が面白いと思う実装例をいくつか紹介したい。
useToggle
on/off を切り替えるトグル。
実装
import { Reducer, useReducer } from 'react';
const toggleReducer = (state: boolean, nextValue?: any) =>
typeof nextValue === 'boolean' ? nextValue : !state;
const useToggle = (initialValue: boolean): [boolean, (nextValue?: any) => void] => {
return useReducer<Reducer<boolean, any>>(toggleReducer, initialValue);
};
export default useToggle;
使用例
import {useToggle} from 'react-use';
const Demo = () => {
const [on, toggle] = useToggle(true);
return (
<div>
<div>{on ? 'ON' : 'OFF'}</div>
<button onClick={toggle}>Toggle</button>
<button onClick={() => toggle(true)}>set ON</button>
<button onClick={() => toggle(false)}>set OFF</button>
</div>
);
};
面白いポイント
-
useState
ではなく、useReducer
を使って toggle を実現している点。 -
useReducer
複雑な状態を管理する場合に用いることが多く、useState
のショートハンド的な記法でトグルを実装できるのが面白い。
react-use では、 toggle(true)
と直接 value を入れることも許容しているが、 value を入れないパターンの toggle だと、以下のように書ける。便利。
useToggle = (initialValue: boolean) => useReducer((v) => !v, initialValue)
useAsyncFn
非同期関数をコンポーネント内で同期的に扱うことを可能にする hooks。
react-query の useMutation や、 @apollo/client の useLazyQuery などのAPIクライアントライブラリで提供される hooks に同様の設計パターンが見られる。
実装
import { DependencyList, useCallback, useRef, useState } from 'react';
import useMountedState from './useMountedState';
import { FunctionReturningPromise, PromiseType } from './misc/types';
export type AsyncState<T> =
| {
loading: boolean;
error?: undefined;
value?: undefined;
}
| {
loading: true;
error?: Error | undefined;
value?: T;
}
| {
loading: false;
error: Error;
value?: undefined;
}
| {
loading: false;
error?: undefined;
value: T;
};
type StateFromFunctionReturningPromise<T extends FunctionReturningPromise> = AsyncState<
PromiseType<ReturnType<T>>
>;
export type AsyncFnReturn<T extends FunctionReturningPromise = FunctionReturningPromise> = [
StateFromFunctionReturningPromise<T>,
T
];
export default function useAsyncFn<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = [],
initialState: StateFromFunctionReturningPromise<T> = { loading: false }
): AsyncFnReturn<T> {
const lastCallId = useRef(0);
const isMounted = useMountedState();
const [state, set] = useState<StateFromFunctionReturningPromise<T>>(initialState);
const callback = useCallback((...args: Parameters<T>): ReturnType<T> => {
const callId = ++lastCallId.current;
set((prevState) => ({ ...prevState, loading: true }));
return fn(...args).then(
(value) => {
isMounted() && callId === lastCallId.current && set({ value, loading: false });
return value;
},
(error) => {
isMounted() && callId === lastCallId.current && set({ error, loading: false });
return error;
}
) as ReturnType<T>;
}, deps);
return [state, (callback as unknown) as T];
}
使い方
import {useAsyncFn} from 'react-use';
const Demo = ({url}) => {
const [state, doFetch] = useAsyncFn(async () => {
const response = await fetch(url);
const result = await response.text();
return result
}, [url]);
return (
<div>
{state.loading
? <div>Loading...</div>
: state.error
? <div>Error: {state.error.message}</div>
: <div>Value: {state.value}</div>
}
<button onClick={() => doFetch()}>Start loading</button>
</div>
);
};
面白いポイント
- 副作用だから
useEffect
を使うのかなと思ったが、実際は、useCallback
を使っているところ。確かにuseEffect
を使って実装すると同様の記述は難しそう。 - 非同期関数なので、
.then
以降の関数呼び出し時には、isMounted()
で、コンポーネントが unmount されていないことをチェックしているのが偉い。- 自分で custom hooks を書いていると、こういうケアを忘れがち。
- APIクライアントの設計で頻出のパターンで、実際に自分で同じような実装をする際の参考になる。
useAsync
は useAsyncFn
があれば簡単に書ける。 useAsync
の実態は、 useEffect
で、 useAsyncFn
の callback を実行しているだけ。これも合わせて覚えておくと良さそう。
import { DependencyList, useEffect } from 'react';
import useAsyncFn from './useAsyncFn';
import { FunctionReturningPromise } from './misc/types';
export { AsyncState, AsyncFnReturn } from './useAsyncFn';
export default function useAsync<T extends FunctionReturningPromise>(
fn: T,
deps: DependencyList = []
) {
const [state, callback] = useAsyncFn(fn, deps, {
loading: true,
});
useEffect(() => {
callback();
}, [callback]);
return state;
}
ちなみに、この isMounted()
で使われる useMountedState
は、以下のように定義されている。 useEffect
の deps が空の場合の return 文は、 unmount 時に呼ばれることを利用している。
import { useCallback, useEffect, useRef } from 'react';
export default function useMountedState(): () => boolean {
const mountedRef = useRef<boolean>(false);
const get = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return get;
}
useClickAway
要素の外側をクリックした際に、 callback を呼び出す hooks。割と使用頻度が高い。
実装
import { RefObject, useEffect, useRef } from 'react';
import { off, on } from './misc/util';
const defaultEvents = ['mousedown', 'touchstart'];
const useClickAway = <E extends Event = Event>(
ref: RefObject<HTMLElement | null>,
onClickAway: (event: E) => void,
events: string[] = defaultEvents
) => {
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
useEffect(() => {
const handler = (event) => {
const { current: el } = ref;
el && !el.contains(event.target) && savedCallback.current(event);
};
for (const eventName of events) {
on(document, eventName, handler);
}
return () => {
for (const eventName of events) {
off(document, eventName, handler);
}
};
}, [events, ref]);
};
export default useClickAway;
使い方
import {useClickAway} from 'react-use';
const Demo = () => {
const ref = useRef(null);
useClickAway(ref, () => {
console.log('OUTSIDE CLICKED');
});
return (
<div ref={ref} style={{
width: 200,
height: 200,
background: 'red',
}} />
);
};
面白いポイント
callback に useRef
を使っている部分が勉強になる。
const savedCallback = useRef(onClickAway);
useEffect(() => {
savedCallback.current = onClickAway;
}, [onClickAway]);
この useRef
があるおかげで、 useClickAway
の クリックの判定処理を行う useEffect
から、 callback が更新処理の依存を外すことができている。
このように、 useEffect
の依存から関心がない関数や値を除きたい場合に、この useRef
+ useEffect
を使った実装パターンが使える。
useLatest
という 似たような hooks も react-use にあり、こちらは、 useEffect
を使わないパターンで実装されており、描画ごとに代入が行われる。
import { useRef } from 'react';
const useLatest = <T>(value: T): { readonly current: T } => {
const ref = useRef(value);
ref.current = value;
return ref;
};
export default useLatest;
まとめ
- react-use に定義されている hooks の実装を見ながら、面白いと思った箇所を紹介した。
- react-use は、広く使われるライブラリなだけありかなりスマートに実装されている。紹介した以外の hooks についても覗いていると楽しい発見があるはず...!
Discussion