PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

React Hooks の基礎

こんにちは、2022 年度新卒エンジニアの中山です。 気づいたらもう配属されて 7 カ月です。 まだまだできないことが多く、コードを読む速度もなかなか上がらず焦っております。

現在私は OTT サービス事業部にて、Web フロントエンドの開発に携わらせていただいております。 私の所属するプロジェクトでは JavaScript のライブラリに React が使われております。 私は React のコンポーネントベースなところと状態管理ライブラリが好きです。 長い処理を上手にコンポーネントに分けられた時や、状態管理を綺麗に書けた時、再利用性のおかげでタスクが一瞬で終わったりした時、React っていいな、先輩すごいな、と思います。

ということで、今回は React に関するテーマです。 React では、コンポーネントを定義する際、クラスコンポーネント、関数コンポーネントの二つの記述法があります。 バージョン 16.8 までは関数コンポーネントで state を扱うことができず、主流の記法はクラスコンポーネントでした。 現在は関数コンポートでも state を制御できるようになっており、React 公式は関数コンポーネントの記法を推奨しています。 私が参画しているプロジェクトも関数コンポーネントで書かれています。

関数コンポーネントは state を扱うために Hooks という仕組みを用います。 Hooks とは関数のようなもので、state の扱い方によって、使用する Hooks を使い分ける必要があります。

今回の主題

ズバリ、今回の主題は React Hooks です。 基本的な Hooks を中心に説明していきます。

useState

状態を保持する変数(state)とその状態を更新する関数(setState)を定義します。

const [state, setState] = useState(initialState);

右辺の initialState が state の初期値となります。 setState(newState) とすると、state の値が、newState に更新されます。

Counter.jsx

import { useState } from "react";

export default function Counter() {
  console.log("render Counter");
  const [count, setCount] = useState(0);
  return (
    <>
      Count: {count}
      <button onClick={() => setCount(0)}>Reset</button>
      <button onClick={() => setCount((count) => count - 1)}>-</button>
      <button onClick={() => setCount((count) => count + 1)}>+</button>
    </>
  );
}

初期値を 0 として現在の値を保持、ボタン入力によって値を更新する Counter コンポーネントです。

const [count, setCount] = useState(0);

useState で count を初期値 0 で定義しています。

<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount((count) => count - 1)}>-</button>
<button onClick={() => setCount((count) => count + 1)}>+</button>

button タグの onClick 属性に count を更新する setCount の処理を定義しています。 Reset ボタンの場合、count には 0 を代入したいため、setCount の引数に 0 を渡しています。 + − ボタンの setCount の引数は現在の count の値に対する処理をさせたいため、関数を渡しています。 現在値と同じ値で更新を行なった場合、React はこのコンポーネント以下のコンポーネントのレンダーを行いません。 下記の実行結果からも分かるとおり、count が 0 の状態で Reset を押してもコンソールに "render Counter" は表示されません。

実行結果

useEffect

useEffect(didUpdate, [dependentArray]);

useEffect は所属するコンポーネントがレンダーされた時、その後に、副作用として実行されます。 didUpdate に実行したい処理を記述します。 dependentArray を依存配列と呼び、これを入力した場合、この配列の要素が変化した時が副作用実行のタイミングとなります。

useEffect(didUpdate, []);

上記のように、依存配列に空配列を入力した場合、 依存配列が変化することはないため、最初のレンダー時のみ実行されます。

Switch.jsx

export default function Switch() {
  const [switchState, setSwitchState] = useState("off");
  useEffect(() => {
    console.log("render useEffect");
    let timeoutId;
    if (switchState === "on") {
      timeoutId = setTimeout(() => {
        setSwitchState("off");
      }, 3000);
    }
    return () => {
      console.log("clean up");
      clearTimeout(timeoutId);
    };
  }, [switchState]);
  return (
    <>
      <button onClick={() => setSwitchState("on")}>switch</button>
      <p>{switchState}</p>
    </>
  );
}

初期値を off として現在の値を保持、switch が押されたら on に更新し、3 秒後 off に戻す Switch コンポーネントです。

const [switchState, setSwitchState] = useState("off");

useState で switch の状態を switchState として定義しています。 useEffect 内で switchState が on の時、3 秒後に off に戻す処理を記述しています。 依存配列は switchState を入力しているため、switch の状態が変わった時のみ、この副作用が実行されます。

return () => {
  console.log("clean up");
  clearTimeout(timeoutId);
};

useEffect 内で setTimeout を使っており、もしこの非同期処理が終わらずにこのコンポーネントがアンマウントされた場合、非同期処理が未解決のまま残るため、メモリリークが発生します。 そのため useEffect で非同期処理を行う場合はアンマウント時に非同期処理を終了させる clean up 処理が必要です。 useEffect では returnした関数に書いた処理が clean up 処理として、次の useEffect がレンダーされる直前とコンポーネントのアンマウント時に実行されます。

実行結果

useContext

const value = useContext(MyContext);

コンテクストオブジェクト MyContext(React.createContext(initialValue)の返り値)を受け取り、そのコンテクストの現在の値を返します。 コンテクストの現在の値は最も近い上層のコンポーネントで定義された値が適用されます。 値の定義は<MyContext.Provider>の value 属性に渡すことで定義します。

直近の<MyContext.Provider>が更新された時、useContext は所属するコンポーネントを最新の value で再レンダーさせます。

Canvas.jsx

import React, { useState } from "react";
import Palette from "./Palette";

const themes = {
  red: { background: "#ff0000" },
  blue: { background: "#1e90ff" },
};
export const ThemeContext = React.createContext();

export default function Canvas() {
  const initialTheme = themes.red;
  const [stateTheme, setStateTheme] = useState(initialTheme);
  return (
    <>
      <button
        style={themes.red}
        onClick={() => {
          setStateTheme(themes.red);
        }}
      >
        red
      </button>
      <button
        style={themes.blue}
        onClick={() => {
          setStateTheme(themes.blue);
        }}
      >
        blue
      </button>
      <ThemeContext.Provider value={stateTheme}>
        <Palette></Palette>
      </ThemeContext.Provider>
    </>
  );
}

Canvas コンポーネントの red ボタンを押せば Palette コンポーネントの A,B,C の背景が赤に、blue ボタンを押せば青になります。 コンポーネントツリーは Canvas>Palette>ThemedPalette という順番で構成されています。

const themes = {
  red: { background: "#ff0000" },
  blue: { background: "#1e90ff" },
};
export const ThemeContext = React.createContext();

Canvas.jsx で背景の色を themes という名前で定義、また ThemeContext という名前で Context オブジェクトを定義しています。 useState で theme の状態を保持、初期値は themes.red にしています。 red ボタンで stateTheme を red に、blue ボタンで stateTheme を blue に更新します。

<ThemeContext.Provider value={stateTheme}>
  <ThemedPalette></ThemedPalette>
</ThemeContext.Provider>

ThemeContext.Provider で value 属性に現在の theme の状態を代入することで ThemeContext に値を持たせられています。 また、ThemeContext.Provider でコンポーネントを囲むことでそのコンポーネント以下すべてのコンポーネントで ThemeContext を参照できます。

Palette.jsx

import ThemedPalette from "./ThemedPalette";

export default function Palette() {
  return <ThemedPalette></ThemedPalette>;
}

ThemedPalette.jsx

import { useContext } from "react";
import { ThemeContext } from "./Canvas";

export default function ThemedPalette() {
  const theme = useContext(ThemeContext);
  return (
    <>
      <p style={theme}>A</p>
      <p style={theme}>B</p>
      <p style={theme}>C</p>
    </>
  );
}

Palette コンポーネントでは ThemedPalette コンポーネントをレンダーしているだけです。 ThemedPalette コンポーネントで useContext を用いて Context の現在値を取り出し、各 p タグのスタイルに適用しています。

実行結果

useReducer

const [state, dispatch] = useReducer(reducer, initialState, init);

useState の拡張 Hooks である useReducer は, useState における setState 関数の処理を reducer に複数まとめて定義できます。 dispatch を実行することで reducer が実行され reducer に定義した複数の処理を実行できます。 state と initialState は useState と同じですが、useState の state には全ての型の値が代入できるのに対して、useReducer の state には object、もしくは配列のどちらかのみ代入可能です。

Counter.jsx

import { useReducer } from "react";
const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.payload };
    case "decrement":
      return { count: state.count - action.payload };
    case "reset":
      return initialState;
    default:
      throw new Error();
  }
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <>
      Count: {state.count}
      <button onClick={() => dispatch({ type: "reset" })}>reset</button>
      <button onClick={() => dispatch({ type: "decrement", payload: 1 })}>-</button>
      <button onClick={() => dispatch({ type: "decrement", payload: 5 })}>-5</button>
      <button onClick={() => dispatch({ type: "increment", payload: 1 })}>+</button>
      <button onClick={() => dispatch({ type: "increment", payload: 5 })}>+5</button>
    </>
  );
}

5 種類のボタンをもつカウンターです。

function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { count: state.count + action.payload };
    case "decrement":
      return { count: state.count - action.payload };
    case "reset":
      return initialState;
    default:
      throw new Error();
  }
}

reducer 関数は state, action を引数に新しい state を返します。 この state に useReducer で定義した state が入り、action には dispatch の引数が入ります。 dispatch は引数を reducer の action オブジェクトとして reducer に渡し、reducer を実行します。 reducer は受け取った action.type の文字列で、実行する処理を分岐させています。 この分岐により、reset、加算、減算の関数を reducer 関数にまとめることができています。 state にはオブジェクト、または配列しか代入できないため、初期値をオブジェクトで定義しています。 そのため count の値は state.count で取得することができます。

<button onClick={() => dispatch({ type: "decrement", payload: 1 })}>-</button>

まとめた関数は dispatch の引数の type プロパティで実行する関数を指定できます。 payload プロパティに入れた値は、reducer 関数内で action.payload という変数名で参照できます。

この例では説明のため、一つの state に対して複数の処理を行う簡単なケースを書きましたが、useReducer を使うべき最適な場面は、複数の state を使った複雑なロジックを実装する場合です。

実行結果

useCallback

useCallback は関数に特化した、レンダリングパフォーマンス最適化のための Hooks であり、 メモ化された関数オブジェクトを返します。 ここで言うメモ化とは、同じ結果を返す処理が複数回呼び出される場合、 初回の結果をキャッシュしておき、2 回目以降は計算せずに初回の結果を返り値として得られるようにする仕組みです。 これにより、プログラムの実行速度を向上させることが可能です。

const memoizedCallback = useCallback(() => {
  anyFunction(argment);
}, [dependentArray]);

anyFunction がメモ化させたい関数です。 React.memo でメモ化されたコンポーネントに props で関数を渡す場合、依存配列に関数を入れる場合、参照整合性(レンダー間の一貫性)を保つために用いられます。 React.memo と組み合わせて使用される場合を例にあげて説明します。

ABCButton.jsx

import React, { useCallback, useState } from "react";

const Button = React.memo(({ onClick, name }) => {
  console.log(`${name} Button was pushed`);
  return <button onClick={onClick}>{name}</button>;
});

export default function ABCButton() {
  const [aFlag, setAFlag] = useState(false);
  const [bFlag, setBFlag] = useState(false);
  const [cFlag, setCFlag] = useState(false);

  const toggleAFlag = useCallback(() => {
    return aFlag ? setAFlag(false) : setAFlag(true);
  }, [aFlag]);
  const toggleBFlag = useCallback(() => {
    return bFlag ? setBFlag(false) : setBFlag(true);
  }, [bFlag]);
  const toggleCFlag = useCallback(() => {
    return cFlag ? setCFlag(false) : setCFlag(true);
  }, [cFlag]);

  //子コンポーネントを呼び出す
  return (
    <>
      <div>
        <Button onClick={toggleAFlag} name="A" />
        <Button onClick={toggleBFlag} name="B" />
        <Button onClick={toggleCFlag} name="C" />
      </div>
      <div>
        {aFlag && <span>A</span>}
        {bFlag && <span>B</span>}
        {cFlag && <span>C</span>}
      </div>
    </>
  );
}

A, B, C のボタンがあり、それぞれのボタンをクリックするとそのアルファベットが表示されます。

<Button onClick={toggleAFlag} name="A" />
<Button onClick={toggleBFlag} name="B" />
<Button onClick={toggleCFlag} name="C" />

Button コンポーネントで各ボタンが押されたかどうかの状態を更新しています。

const Button = React.memo(({ onClick, name }) => {
  console.log(`${name} Button was pushed`);
  return <button onClick={onClick}>{name}</button>;
});

この Button コンポーネントは React.memo でメモ化されており、onClick 関数 か name に変更があった時、再レンダーされます。 この onClick にボタンの状態を更新する関数が入ります。

const toggleAFlag = useCallback(() => {
  return aFlag ? setAFlag(false) : setAFlag(true);
}, [aFlag]);

ボタンの状態を更新する関数は useCallback によって、対応する各ボタンの状態が変わった時のみ再生成されるように設定されています。 この設定によって console からも分かるとおり、A ボタンを押した時は A ボタンの Button コンポーネントのみレンダーさせることができています。 もし、useCallback を使用しない場合、これら toggleFlag 関数は Counter コンポーネントがレンダーされる度に再生成されるため、React.memo で handleClick の参照整合性の比較が false になり、全てのボタンコンポーネントがレンダーされてしまいます。

他の使用目的として「レンダーの度に同じ処理を行う関数が再生成されることを防ぐこと」が考えられるかもしれませんが、この使用方法は最適化にはならず、ほとんど変わらないか、ほんの少し悪化する可能性があるようです。 1

実行結果

useMemo

useMemo も useCallback と同じく、レンダリングパフォーマンス最適化のための Hooks です。

const memoizedValue = useMemo(() => {
  anyHeavyFunction(argment);
}, [dependentArray]);

useCallback がメモ化された関数オブジェクトを返すのに対して、useMemo は計算結果の値のみを返します。 処理が重い関数の結果をメモ化することでパフォーマンスを向上できます。

Calculator.jsx

import "./styles.css";
import { useState, useMemo } from "react";

export default function Calculator() {
  const [inputState, setInputState] = useState(1);
  const [reRenderCountState, setReRenderCountState] = useState(0);
  console.log("rendered Calculater");
  // const output = expensiveCalculation(inputState);
  const output = useMemo(() => expensiveCalculation(inputState), [inputState]);
  const onChange = (event) => {
    setInputState(Number(event.target.value));
  };
  const onClick = () => {
    setReRenderCountState((i) => i + 1);
    console.log("reRenderCountState", reRenderCountState);
  };

  return (
    <div>
      input:
      <input type="number" value={inputState} onChange={onChange} />
      <p>output: {output}</p>
      <button onClick={onClick}>Re-render</button>
    </div>
  );
}

const expensiveCalculation = (num) => {
  console.log("Calculating...");
  for (let i = 0; i < 1000000000; i++) {
    num += 1;
  }
  console.log("result", num);
  return num;
};

input に入れた値に 1000000000 を足して output に出力するコンポーネントです。

const [inputState, setInputState] = useState(1);
const [reRenderCountState, setReRenderCountState] = useState(0);

inputState を初期値 1 で定義、Re-render ボタンを押された回数を reRenderCountState で定義しています。 input 要素の値が変化した時と Re-render ボタンが押された時、state が変わるため、再レンダーが走ります。 output に expensiveCalculation の返り値を代入し、それを p タグで表示しています。

const expensiveCalculation = (num) => {
  console.log("Calculating...");
  for (let i = 0; i < 1000000000; i++) {
    num += 1;
  }
  console.log("result", num);
  return num;
};

expensiveCalculation が 1000000000 を足す関数です。 あえて 1 ずつループで足すことで、重い処理にしています。

const output = useMemo(() => expensiveCalculation(inputState), [inputState]);

expensiveCalculation の結果を useMemo で依存配列を inputState としてメモ化しています。 これにより、Re-render ボタンを押されたときは計算はせず、結果のみ返して、input が変化した時のみ再計算させることができています。 useMemo を外した場合、Re-render を押した時にも計算時の遅延が発生し、console には expensiveCalculation に記述した console.log が表示されます。

実行結果

useRef

「DOM の操作」または「更新時に再レンダーを行わない state」の実装を目的に使われます。

const refContainer = useRef(initialValue);

例(DOM の操作)

OperateDOM.jsx

import { useRef } from "react";

export default function OperateDOM() {
  console.log("rendered");
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // inputEl.currentでinput要素を参照
    inputEl.current.focus();
    inputEl.current.style.color = "red";
  };
  const onChange = (event) => {
    inputEl.current.value = event.target.value;
  };
  return (
    <>
      <input ref={inputEl} type="text" onChange={onChange} />
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}
<button onClick={onButtonClick}>Focus the input</button>

Focus the input ボタンを押すと、input 要素にフォーカスし、その色を赤に変えるコンポーネントです。

const inputEl = useRef(null);

まず、useRef で任意の名前の ref オブジェクトを作成します。

<input ref={inputEl} type="text" onChange={onChange} />

その ref オブジェクトを input 要素の ref 属性で参照し、DOM に接続します。 onButtonClick 関数 と onChange 関数で、 ref オブジェクトの current プロパティを参照し、DOM の属性値を変更することで DOM の操作ができています。

実行結果

例(更新時に再レンダーを行わない state)

Counter.jsx

import { useState, useRef } from "react";

export default function Counter() {
  const [countState, setCountState] = useState(0);
  const countRef = useRef(0);
  const handleOnClick = () => setCountState(countState + 1);
  const handleOnClick2 = () => countRef.current++;

  console.log("render");

  return (
    <div>
      <div>{countState}</div>
      <button onClick={handleOnClick}>Count up with useState</button>
      <div>{countRef.current}</div>
      <button onClick={handleOnClick2}>Count up with useRef</button>
    </div>
  );
}

useState で countState を定義、Count up with useState ボタン押下時に countState を +1 しています。 また、useRef で countRef を定義、Count up with useRef ボタン押下時に countRef.current を +1 しています。 「Count up with useState」ボタンを押すと数字が 1 増加します。 一方、「Count up with useRef」ボタンを押すと変化しません。 これは、useRef で定義した refObject が変化しても再レンダーが走らないからです。 「Count up with useRef」押下後に「Count up with useState」ボタンを押すと「Count up with useRef」の変更が画面に反映されることからも明らかです。 このように、コンポーネント内で値を保持したいが、画面に更新した内容をリアルタイムで表示させたくない場合にも、useRef が利用できます。

実行結果

まとめ

今回は React Hooks について説明しました。 実行結果で実際に動作しているところを確認できますので、やってみていただけると嬉しいです。 ありがとうございました!