Chapter 08

状態管理

Thirosue
Thirosue
2021.09.08に更新

Reactでのグローバルな状態管理には、Reduxがよく用いられていましたが、Context APIで状態管理できるようになったので、Context APIを採用しました。

Reduxを見送った理由は、学習コストの高さを感じたのと、実装を見比べた結果、採用のメリットを感じなかったからです。

なお、以下などの情報を参考にしました。

https://youtu.be/OvM4hIxrqAw

アプリ全体で管理する情報

グローバルステートで管理している情報は、以下のみです。

  • ログインセッション情報(jwtトークンなど)
data/global-state.ts
import { Session } from './session'

export type GlobalState = {
  session: Session
}
data/session.ts
export type Session = {
  username?: string
  sub: string
  jwtToken: string
  email_verified?: boolean
}

カスタムプロバイダー作成

アプリで管理する情報と情報を更新するメソッドをラップして、プロバイダーに設定します。

context/global-state-provider.tsx
const GlobalStateProvider = ({
  children,
}: {
  children: React.ReactNode
}): JSX.Element => {
// グローバルステート
  const [state, setState] = useState<GlobalState>(initState)

// 状態を操作する関数
  const clearState = (): void => {
    setState({ ...INIT_STATE })
  }

  const updateState = (value: GlobalState): void => {
    setState({ ...state, ...value })
  }

  const renewToken = (token: string): void => {
    setState({
      ...state,
      ...{ session: { jwtToken: token, sub: state.session.sub } },
    })
  }

  const isSignin = () => !!state.session.sub

// 状態と関数をオブジェクトにラップして、プロバイダーに引き渡す
  const global = {
    state,
    updateState,
    renewToken,
    clearState,
    isSignin,
  }

...(中略)...

  return (
    <GlobalContext.Provider value={global}>{children}</GlobalContext.Provider>
  )
}

利用設定

アプリ全体で管理する情報であるため、レイアウトコンポーネントに状態を利用できるプロバイダーを設定しておき、各画面では意識せずグローバルステートを利用できるようにします。

components/template/dashboard-layout.tsx
import ConfirmProvider from '../../context/confirm-provider'

...(中略)...

export const DashboardLayout = ({
  children,
  title,
}: {
  children: React.ReactNode
  title: string
}): JSX.Element => {

  return (
    <>
      <QueryClientProvider client={queryClient}>
        <ConfirmProvider>
          <GlobalStateProvider> <!-- 状態管理プロバイダーでラップする --->
            <Seo title={title} />
            <div className="flex h-screen bg-gray-200 font-roboto">
              <SideBar
                sidebarOpen={sidebarOpen}
                toggle={() => setSidebarOpen(false)}
              />
              <div className="flex-1 flex flex-col overflow-hidden">
                <Header toggle={() => setSidebarOpen(true)} />
                <main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-200">
                  {children}
                </main>
              </div>
            </div>
          </GlobalStateProvider>
          <ReactQueryDevtools initialIsOpen={false} />
        </ConfirmProvider>
      </QueryClientProvider>
      <ToastContainer
        autoClose={3000}
        hideProgressBar={false}
        newestOnTop={false}
        closeOnClick
        rtl={false}
        position={'bottom-right'}
        pauseOnFocusLoss
        draggable
        pauseOnHover
      />
    </>
  )
}

利用方法

各画面では、useContextフックを利用し、グローバルステートとグローバルステートを更新する関数を利用します。

components/page/login-page.tsx
export const LoginPage = ({
  passwordModalOpen,
}: {
  passwordModalOpen: () => void
}): JSX.Element => {
  const router = useRouter()
  const mutation = useMutation(
    (req: AuthRequest): AxiosPromise<AuthResponse> => AuthRepository.signIn(req)
  )
  const context = useContext(GlobalContext) // useContextでグローバルステートを利用する
  
  const doSubmit = (data: FormValues): void => {
    captains.log(data)
    const authRequest: AuthRequest = {
      id: data.email,
      password: data.password,
    }
    mutation.mutate(authRequest, {
      onSuccess: async (res: AxiosResponse<AuthResponse>) => {
        // セッション情報を更新する
        context.updateState({
          session: {
            username: data.email,
            jwtToken: res.data.token,
            sub: 'sub',
          },
        })
        await router.push('/')
        setTimeout(() => toast('ログインしました'), 100) // display toast after screen transition
      },
    })
  }

リロード時に状態をcookieから引き戻す

ブラウザでリロードした場合(WindowsのF5)など、特段の対応をしない場合、メモリにのみ状態が保存されているため、グローバルステートの情報はクリアされてしまいます。
cookieにステートを同期し、リロード時は値を引き戻すことでブラウザのリロード時にもセッション状態は保持されるようになります。

上記は、フックを用いて、以下のように対応しました。

  • リロード時の状態引き戻し
context/global-state-provider.tsx
const initState = (): GlobalState => {
 // cookieから状態を引き戻す
  const cookie = parseCookies(null)
  let state = cookie.state
    ? (_.attempt(JSON.parse.bind(null, cookie.state)) as GlobalState)
    : { ...INIT_STATE }

  captains.log(state)
  if (_.isError(state) || !state) {
    state = { ...INIT_STATE }
  }
  return {
    ...state,
  }
}

const GlobalStateProvider = ({
  children,
}: {
  children: React.ReactNode
}): JSX.Element => {
  const router = useRouter()
  const [state, setState] = useState<GlobalState>(initState) // フックの初期化時に上記関数を利用
  
...(中略)...

 //ステート変更時は、cookieに同期する
  useEffect(() => {
    setCookie(null, 'state', JSON.stringify(state), {
      // 60秒 * 60 * 24 * 3650 日間保存
      maxAge: 24 * 60 * 60 * Const.SessionRetentionPeriod,
      secure: true,
    })
  }, [state])

その他

Facebook産のRecoilが用いられる例も多くなっているようですが、コンテクストAPIを利用することによる明確な課題に直面していないため、採用は見送っています。

https://ics.media/entry/210224/