useEffectを理解する

f:id:yun8boo:20200410111432j:plain

こんにちは。フロントエンドエンジニアの渡邉です。 スタメンで開発しているサービスの新機能は、React v16.8で追加されたhooks等を使って開発しています。 その中でも本記事ではuseEffectについて触れていこうと思います。

目次

  • hooksとは
  • useEffectの第二引数の依存リストを正確に渡す
  • 参考サイト

hooksとは

フック (hook) は React 16.8 で追加された新機能です。state などの React の機能を、クラスを書かずに使えるようになります。

React 公式ドキュメント

useEffect

useEffectは第1引数にrender毎に実行する関数を。第2引数はrender時にuseEffect内の関数を実行するか判定する値を配列で渡します。 以下コードのように第2引数に何も渡さないとrender毎に実行されます。

const Counter = () => {
  const [count, setCount] = useState(0); 
  const [count2, setCount2] = useState(1);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return (
    <div>
      <span>You clicked {count} times</span>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <span>You clicked {count2} times</span>
      <button onClick={() => setCount2(count2 + 1)}>count2 Click me</button>
    </div>
  );
}

countが変わった時限定で実行したい場合は第2引数にcountを渡す。 これでcount以外のstateが更新されても、useEffectが実行されることはなくなります。

const Counter = () => {
  const [count, setCount] = useState(0);
  const [count2, setCount2] = useState(1);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  }, [count]); // countが変わったら実行される
  return (
    <div>
      <span>You clicked {count} times</span>
      <button onClick={() => setCount(count + 1)}>Click me</button>
      <span>You clicked {count2} times</span>
      <button onClick={() => setCount2(count2 + 1)}>count2 Click me</button>
    </div>
  );
}
// 初期 render
const Counter = () => {
  // ...
  // 初期render
  useEffect(() => {
    document.title = `You clicked ${0} times`;
  },[count]);
  // ...
}

// `<button onClick={() => setCount(count + 1)}>Click me</button>`がクリックされると、Counter関数が呼び出される
const Counter = () => {
  // ...
  // 再renderでcountが変わっているのでuseEffect内の関数が実行される
  useEffect(() => {
    document.title = `You clicked ${1} times`;
  },[count]);
  // ...
}

// `<button onClick={() => setCount2(count2 + 1)}>count2 Click me</button>`がクリックされると、再度Counter関数が呼び出される
const Counter = () => {
  // ...
  // count2が変わったが、countは変わっていないのでuseEffect内の関数は実行されない
  useEffect(() => {
    document.title = `You clicked ${1} times`;
  },[count]);
  // ..
}

useEffectの第2引数の依存リストを正確に渡す

第2引数に空配列を渡しているパターンは要注意です。

公式ドキュメント引用

関数を依存のリストから安全に省略できるのは、その関数(あるいはその関数から呼ばれる関数)が props、stateないしそれらから派生する値のいずれも含んでいない場合のみです。

例えば、以下コードにはバグがあります。

const dataPage = ({dataId}) => {
  const [data, setData] = useState(null); 

  const getUrl = () => {
    return `http://api/data/${dataId}`
  }
  const fetchData = async () => {
    const url = getUrl()
    const response = await fetch(url);
    const json = await response.json();
    setData(json);
  }
  
  const testFunc =  () => {
    //  処理
  }
  
  const testFunc2 =  () => {
    //  処理2
  }
  
  const testFunc3 =  () => {
    //  処理3
  }

  useEffect(() => {
    fetchData();
  }, []);
  // ...
}

上のコードの場合dataIdに変更があった場合でもfetchDataは実行されず、古いdataが表示され続けてしまう。 このように、propsやstateを参照している関数がuseEffectの外で作成されると、propsとstateが実際に扱われているかを把握するには、アプリケーションが肥大化するに連れ難しくなり、バグを生みやすくしてしまう。

const dataPage = ({dataId}) => {
  const [data, setData] = useState(null);
  
  const getUrl = () => {
    return `http://api/data/${dataId}` // propsで渡ってきたdataIdを使用している
  }
  const fetchData = async () => {
    const url = getUrl()
    const response = await fetch(url);
    const json = await response.json();
    setData(json);
  }
  
  const testFunc =  () => {
    //  処理
  }
  
  const testFunc2 =  () => {
    //  処理2
  }
  
  const testFunc3 =  () => {
    //  処理3
  }

  useEffect(() => {
    fetchData(); // 実行
  }, []); // 空配列を渡しているため、dataIdが変更されても古いdataのまま表示されてしまう
  // ...
}

なので、依存リストに正確に渡す方法としての基本useEffect内で実行する関数はuseEffect内で宣言するのと、useEffect内で使われている値はすべて記述することです。 なので実際に先程のコードを修正します。

const dataPage = ({dataId}) => {
  const [data, setData] = useState(null);
    const testFunc =  () => {
    //  処理
  }
  
  const testFunc2 =  () => {
    //  処理2
  }
  
  const testFunc3 =  () => {
    //  処理3
  }
  useEffect(() => {
    const getUrl = () => {
      return `http://api/data/${dataId}` // propsで渡ってきたdataIdを使用している
    }
    const fetchData = async () => {
      const url = getUrl()
      const response = await fetch(url);
      const json = await response.json();
      setData(json);
    }
    fetchData(); // 実行
  }, [dataId]);
  // ...
}

これによりuseEffect内でしか使われていないので、仮にdataIdを使わなくなったとしても気づきやすくなります。

useEffect内に関数を移動できない場合

その場合の選択肢として、コンポーネントの外部にその関数を移動できないかを考える必要があります。

例えば、testFuncを外部に移動することによりpropsもstateも参照していないことが保証されるため、依存リストに含めなくても良くなるので、空配列を渡しても安全です。

 const testFunc =  () => {
   console.log('test')
 }
 
const dataPage = ({dataId}) => {
  const [data, setData] = useState(null);
  // ...
  
  useEffect(() => {
    testFunc()
  }, []);
  
  // ...
}

propsやstateを参照していて外部に移動できない場合の解決策としてuseCallbackを使用する方法があります。 例えば、親のコンポーネントが、自身のstateを変更させるアクションを子コンポーネントに渡して、子コンポーネントで発火させる!などなど。

useCallbackを使う

useCallbackとは... 関数自体の依存が変わらない限り関数も変化しないことを保証できる(メモ化されたコールバックを返す)

以下のようにParentコンポーネントで自身のpropsを参照している関数をコンポーネントに渡すして実行する場合

const Parent = ({id}) => {
  const[name, setName] = useState('');
  const hogeFunc = () => {
    console.log(id)
  }
  retrun (
   //... 
   
      <Child hogeFunc={hogeFunc} />
      
   //...
  )
}

const Child = ({hogeFunc}) => {
  useEffect(() => {
    hogeFunc()
  }, [hogeFunc])
  //...
}

render内でアロー関数を利用すると常に新規の関数オブジェクトを作成してしまいます。 そのため、その関数を子コンポーネントに渡した場合render毎に前回渡ってきた関数と違う関数として扱われてしまいます。 なので上のようなコードだとstateのnameが更新されて、Parentコンポーネントが再描画されるたびに、Childに渡るhogeFuncは新規の関数として渡してしまうので、useEffect内は毎回実行されてしまう。

そのようなときにuseCallbackの出番です。 useCallbackは「意味的に同じ関数」が返るかどうかを判別して、同じ値を返す関数が返るべきなら新規のアロー式を捨てて、前に渡した同じ結果を返す関数です。

const memoizedCallback = useCallback(() => {
    doSomething(a, b);
  }, [a, b]) // useCallbackの第2引数もuseEffectの第2引数と同じ考え方;

実際にuseCallbackを使って先程のコードを修正します。

const Parent = ({id}) => {
  const[name, setName] = useState('');
  // hogeFuncはidに変更が合った場合のみ新規関数として作られる。
  const hogeFunc = useCallback( () => {
    console.log(id)
  }, [id])
  retrun (
   //... 
   
      <Child hogeFunc={hogeFunc} />
      
   //...
  )
}

const Child = ({hogeFunc}) => {
  useEffect(() => {
    hogeFunc()
  }, [hogeFunc])  // Parentコンポーネントのidが変わった場合のみhogeFuncが新規関数として作成されるので、再描画の度に実行されることはなくなる
  //...
}

useCallbackを使い常に新規の関数オブジェクトを作成しなくなることにより利点が他にもあります。 ChildコンポーネントがReact.memo()されている場合です。

React.memo()はpropsの変更をチェックして、同じ結果の場合は再描画をスキップして、変更があった場合のみ再描画をするので、無駄なレンダーを減らすことができます。

const Parent = ({id}) => {
  const[name, setName] = useState('');
  const hogeFunc = useCallback(() => {
    console.log(id)
  }, [id])
  return (
   //... 
   
      <Child hogeFunc={hogeFunc} />
      
   //...
  )
}

const Child = React.memo(({hogeFunc}) => {  // hogeFuncに変更がない限りChildコンポーネントは再描画されない。
  useEffect(() => {
    hogeFunc()
  }, [hogeFunc]) 
  //...
})

React.memo()の詳細は今回省くので公式ドキュメントにてお願いします。

参考サイト

useEffect完全ガイド これを読むとuseEffectマスターになれると思います。 少し長いですが、完全に理解したい方にはおすすめです。

まとめ

最近はhooksのお手軽さからFunctionalComponentで書くことが多いですが、きちんと理解した上で書いていきたいと記事をまとめている時に改めて感じました。

最後に、株式会社スタメンでは一緒に働くエンジニアを募集しています。ご興味のある方はぜひエンジニア採用サイトをご覧ください。