👍

react-use の実装から学ぶ custom hooks

2021/07/26に公開

react-use とは

  • React の便利 custom hooks が定義されているライブラリ。
  • 頑張って実装した hooks が「react-use にすでにありますよ」と指摘されることがよくある。

https://github.com/streamich/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-queryuseMutation や、 @apollo/clientuseLazyQuery などの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クライアントの設計で頻出のパターンで、実際に自分で同じような実装をする際の参考になる。

useAsyncuseAsyncFn があれば簡単に書ける。 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 についても覗いていると楽しい発見があるはず...!
脚注
  1. 執筆時点の react-use の最新の commit を参考にしている。 ↩︎

Discussion