28
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ReactAdvent Calendar 2021

Day 10

[React,react-hooks,closure]そのstate更新がうまくいかないのは、stale-closureのせいかも?

Last updated at Posted at 2021-11-28

はじめに

Reactで非同期処理を用いてStateを更新する際、処理の書き方に気をつけないとクロージャの性質によって最新の値を基にStateが更新されないことがある。
useEffectで値のSubscribeをしてるときなどは、特に注意が必要である。(筆者はこれでハマったため)
本稿は、stale-closure(古くなったクロージャ)が引き起こすstate更新での問題とは何か?とその解決策を解説する。

stale-closureによってどんな問題が起きるか

例えば、以下のようにsetIntervalを用いて1秒ごとにカウントアップを行うプログラムがあったとする。

CodeSandboxはこちら

import { useEffect, useState } from "react";
export default function App() {
  const [count, setCount] = useState(0);

  const countUp = () => {
    return setInterval(() => {
      setCount(count + 1);
    }, 1000);
  };

  useEffect(() => {
    countUp();
  }, []);

  return <div>{count}</div>;
}

このプログラムは1ずつカウントアップしていくことが期待されるが実際にはそうはならず、一度1に上がった後それ以上カウントアップされない。

期待される挙動: 0から順に1ずつカウントアップされる
実際の挙動:カウントは1以上にならない

(ここでは非同期処理の例としてsetIntervalを使っているが、更新用の関数の作成タイミングと実行タイミングがずれていることが重要で、頭の中で何らかの非同期処理と置き換えてもらうとご自身のユースケースと重ねやすいかもしれない.)

countステートはAppコンポーネントに定義されているし、countUp関数はそのステートを間違いなく参照しているように見えるのになぜこうなるのか??

原因

この問題を引き起こしているのはstale-closure、つまり古くなった(鮮度の落ちた)クロージャである。

クロージャとは

ここで一度Javascriptのクロージャについて軽くまとめておく。

クロージャは、組み合わされた(囲まれた)関数と、その周囲の状態(レキシカル環境)への参照の組み合わせです。言い換えれば、クロージャは内側の関数から外側の関数スコープへのアクセスを提供します。JavaScript では、関数が作成されるたびにクロージャが作成されます。

出典:https://developer.mozilla.org/ja/docs/Web/JavaScript/Closures

これだけだとよくわからないが、以下の説明でしっくりきた。

クロージャは関数とその関数が作られた環境という 2 つのものの組み合わせです。この環境は、クロージャが作られた時点でスコープ内部にあったあらゆる変数によって構成されています。

出典:同上

つまり、クロージャは関数を作成した時点での変数の値と関数を保持しているものなのである。

今回のコードでの問題点

const countUp = () => {
    return setInterval(() => {
      setCount(count + 1);
    }, 1000);
  };

上の部分で、countUp関数を作成する際にsetInterval関数の中にあるcountはクロージャに捕らえられてしまい、この時点でのcountの値(=0)を保持し続けることになってしまっていたのである。そのため、setIntervalで何度setCountが実行されようと 0+1を延々と繰り返し、カウントアップできなかったのである。

詳細な流れ

1.初期描画(countUp作成)
2.useEffect実行(countUp呼び出され、setIntervalが実行される)
3.+1秒後 0(初期値) + 1 = 1 -> setState
4.再描画 ここでstale-closure発生!!
dependenciesを[]にしているため、再描画されてもuseEffectは実行されない。
そのため再描画で新しく作られたcountUpではなく、初期描画時のcountUpが使われる.
そしてこのcountUpはクロージャによって古い値を参照したままになっている)
5.+1秒後 0(初期値) + 1 = 1 -> setState
stateの値が変わらないため、以後再描画はされない
6.+1秒後 0(初期値) + 1 = 1 -> setState
...
n.+1秒後 0(初期値) + 1 = 1 -> setState

useState単体使用の例

上記ではuseEffectを例として出したがuseState単体を用いても非同期処理が絡めば、stale-closureが悪さをすることはある。
CodeSandboxはこちら

import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);

  const dummmy = () => {
    setTimeout(() => {
      setCount(count + 1);
    }, 1000);
  };

  return (
    <>
      <div>{count}</div>
      <button onClick={dummmy}>Button</button>
    </>
  );
}

上記のコードで素早くボタンを複数回押すと、カウントアップは1ずつしかされないことが確認できるはずである。
素早くとしているのは、もし連打ではなくカウントアップ(state更新)を待ってからボタンを押した場合は、state更新に伴う再描画により関数も再作成されて、新しいクロージャが作成されstale-closureが発生しないためである。

対策

さて、ここまででstale-closureがstate更新でどのように悪さをするかは述べた。
では、どのようにすればこのstale-closureを回避できるのか。
ここでは2つの方法を紹介する。(他にもあるとは思うのでコメントお待ちしてます)
※useEffectにdependenciesを設定する方法もあるかとは思うが、setIntervalの例では、setIntervalを実行するたびにタイマー処理が追加されていき,countの増加が不自然になったので今回は例としてわかりづらいので割愛)

useState(prevState=> {}) を利用する

クロージャに捕らえられていた古い値を参照していたのが今回の問題なのでuseState関数の機能を使って常に最新のstateを参照して処理を行うようにする。

※useStateはuseState(prevState=> {})のように記述することで最新のstateを参照して任意の処理を行うことができる

CodeSandboxはこちら

import { useEffect, useState } from "react";

export default function App() {

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

  const countUp = () => {
    return setInterval(() => {
      console.log(count);
      setCount((prevState) => prevState + 1);
    }, 1000);
  };

  useEffect(() => {
    countUp();
  }, []);

  return <div>{count}</div>;
}

useReducerを使う

useReducerを使い、更新処理関数の中ではdispatchを行うだけにする。
これにより、closureが新鮮かどうかを気にせず常にreducerで最新のstateを基に処理を行うことができる

CodeSandboxはこちら

import { useEffect, useReducer, useState } from "react";

const reducer = (state, action) => {
  switch (action.type) {
    case "COUNT_UP":
      return state + 1;
    default:
      return state;
  }
};

export default function App() {
  const [count, dispatch] = useReducer(reducer, 0);

  const countUp = () => {
    return setInterval(() => {
      dispatch({ type: "COUNT_UP" });
    }, 1000);
  };

  useEffect(() => {
    countUp();
  }, []);

  return <div>{count}</div>;
}

実践的なユースケース 随時更新

ここまでで、stale-closureに気をつけてstate更新をする手法をsetIntervalやsetTimeoutを用いて説明してきた。以下ではもう少し具体的にこの知見が活かせるケースを紹介する。
というよりも、実は筆者が以下の処理を技術書で学習しているときに「useStateだとclosureが悪さするからuseReducerにしているよ」と言った記述があり、何を言っているんだ?となったのがこの記事執筆のきっかけである。

subscription処理

以下にAmplifyでデータの追加を検知してstate更新を行うアプリケーションの一部を抜粋して掲載する.
Amplifyを具体的に出しているが、Amplifyに限らず購読処理を行う場合であれば本稿の対策が同様に当てはめられるはずである。

import {OnCreatePostSubscription, Post} from "./API";
import {API, graphqlOperation, Storage} from "aws-amplify";
import {useEffect, useReducer, useState} from "react";
import {listPosts} from "./graphql/queries";
import {isDefined} from "./utils";
import {GraphQLResult} from '@aws-amplify/api-graphql'
import {onCreatePost} from "./graphql/subscriptions";

interface SetPosts {
    type: 'SET_POSTS',
    posts: ClientPost[]
}

interface AddPost {
    type: 'ADD_POST',
    post: ClientPost
}

type Action = SetPosts | AddPost

type ClientPost = {
    id: string;
    imageKey: string;
    title: string;
    imageUrl: string;
}

const reducer = (state: ClientPost[], action: Action) => {
    switch (action.type) {
        case 'SET_POSTS':
            return action.posts;
        case "ADD_POST":
            return [action.post, ...state]
        default:
            return state;
    }
}

const getSignedPosts = async (posts: Post[]): Promise<ClientPost[]> => {
    const signedPosts = await Promise.all(
        posts.map(async (item) => {
            if (!item.imageKey) return;
            const signedUrl = await Storage.get(item.imageKey)
            const clientPost: ClientPost = {
                id: item.id,
                imageKey: item.imageKey,
                title: item.title,
                imageUrl: signedUrl
            };
            return clientPost
        })
    )
    return signedPosts.filter(isDefined);
}
type PostSubscriptionEvent = { value: { data: OnCreatePostSubscription } };

const Posts = () => {
    const [posts, dispatch] = useReducer(reducer, [])
    const update = async ({value: {data}}: PostSubscriptionEvent) => {
        const newPost = data.onCreatePost;
        if (!newPost || !newPost.imageKey) return;
        const signedUrl = await Storage.get(newPost.imageKey)
        const newClientPost: ClientPost = {
            id: newPost.id,
            imageKey: newPost.imageKey,
            title: newPost.title,
            imageUrl: signedUrl
        }
        dispatch({type: 'ADD_POST', post: newClientPost})
    }

    useEffect(() => {
        fetchPosts();
        const client = API.graphql(
            graphqlOperation(onCreatePost)
        )
        if ("subscribe" in client) {
            const subscription = client.subscribe({
                next:update
            })

            return () => subscription.unsubscribe();
        }

    }, [])
    const fetchPosts = async () => {
        const postData = await API.graphql(graphqlOperation(listPosts)) as GraphQLResult<{listPosts:{items:Post[]}}>
        const items = postData.data?.listPosts.items;
        if (!items) return;
        const signedPosts = await getSignedPosts(items)
        dispatch({type: 'SET_POSTS', posts: signedPosts})
        updatePost(signedPosts)
    }
    return (
        <div>
            <h2 style={heading}>Posts</h2>
            {posts.map(post => (
                <div key={post.id} style={postContainer}>
                    <img style={postImage} src={post.imageUrl} alt=""/>
                    <h3 style={postTitle}>{post.title}</h3>
                </div>
            ))}
        </div>
    )
}

const postContainer = {
    padding: '20px 0px 0px',
    borderBottom: '1px solid #ddd'
}

const heading = {margin: '20px 0px'};
const postImage = {width: 400};
const postTitle = {marginTop: 4}

export default Posts;

注目して欲しいのはstate更新処理とuseEffectの部分である。
state更新処理(正確にはdispatchしてるだけ)

const update = async ({value: {data}}: PostSubscriptionEvent) => {
        const newPost = data.onCreatePost;
        if (!newPost || !newPost.imageKey) return;
        const signedUrl = await Storage.get(newPost.imageKey)
        const newClientPost: ClientPost = {
            id: newPost.id,
            imageKey: newPost.imageKey,
            title: newPost.title,
            imageUrl: signedUrl
        }
        dispatch({type: 'ADD_POST', post: newClientPost})
    }

useEffectでSubscriptionの設定をしている部分

useEffect(() => {
        fetchPosts();
        const client = API.graphql(
            graphqlOperation(onCreatePost)
        )
        if ("subscribe" in client) {
            const subscription = client.subscribe({
                next:update
            })

            return () => subscription.unsubscribe();
        }

    }, [])

上記はデータが追加された際にstateを更新するというSubscription処理だが、これは今回の解説で示してきたコードと同じくuseEffectの中でstateを更新する関数を非同期で実行するケースである。

したがって、subscribeで取得した値(state)の管理はuseStateではなく、useReducerを用いており、update関数ではdispatchするに留めて、常にreducerの中で最新のstateをもとに更新処理が行われている。

もし、これをuseStateで行った場合は、subscribeした値を初期値([])に加えるだけになり、stateは[subscribeした値]となる。
本来得たい(初期フェッチで得た値 + subscribeした値)の配列にはならない。

また、対策セクションで述べたように以下のようにuseState(prevState=>{})を利用しても更新可能である。

const newClientPost: ClientPost = {
            id: newPost.id,
            imageKey: newPost.imageKey,
            title: newPost.title,
            imageUrl: signedUrl
        }
updatePost(prevState => [...prevState,newClientPost])

#おわりに
本稿が皆さんの「なんでうまくstate更新されないんだ!!」問題の一助になれば

参考

28
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
19

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?