Apollo ClientでState管理をやってみる
はじめに
最近、graphqlを触るようになりApollo Clientを使い始めるようになりました。
せっかくだからState管理もApolloでできたらいいなと思って、試行錯誤してる最中を言語化してみたものです。
ちゃんと理解できてない自信があり参考になるか全くわかりませんが、Apollo ClientでState管理興味あるぜ!って方はぜひ読んでみてください。
もし認識間違っているところあれば教えていただきたいです!
Appで管理が必要なStateを5つに分類
まず、Apolloの公式ブログを参考にアプリで管理が必要なStateを5つに分類します。
Local state
- 1つのコンポーネント内で完結するstate
 - 例: ui関連(tooltip/modalの開閉), form関連(input/selectbox等のvalue)
 - useStateを使用
 
Global state
- 複数のコンポーネントで横断的に使用するstate
 - 例: ui関連(tooltip/modalの開閉), form関連(input/selectbox等のvalue)
 - Reactive variablesを使用
 
Remote state
- APIから取得するstate
 - 例: ドメインデータ
 - useQueryを使用
 
Meta state
- stateに関するstate
 - 例: loading, error
 - useQuery, useMutationを使用
 
Router state
- 現在のURL
 - 例: /login, s=keyword
 - useRouterを使用
 
実装方法
次に上記で上げた5つのStateを具体的にどのように実装していくかのサンプルコードを考えてみました。
※Apollo Clientはv3以上、Next.js/typescriptの環境を前提にしてます。
Local state
同一コンポーネントでしか使用しないstateはuseStateを使用します。
定義
useStateはカスタムフックに切り出す。
import { useState } from "react";
type Hooks = {
  state: boolean;
  handleToggle: () => void;
};
export const useHooks = (): Hooks => {
  const [state, setState] = useState<boolean>(false);
  const handleToggle = () => {
    setState((state) => !state);
  };
  return { state, handleToggle };
};
使用
カスタムフックをコンテナコンポーネントで呼び出し、プレゼンテーショナルコンポーネントに渡す。
// Container
import type { VFC } from "react";
import { useHooks } from "./hooks";
import { Presenter } from "./presenter";
export const Sample: VFC = () => {
  const { state, handleToggle } = useHooks();
  return <Presenter state={state} handleToggle={handleToggle} />;
};
// Presentational
import type { VFC } from "react";
export type Props = {
  state: boolean;
  handleToggle: () => void;
};
export const Presenter: VFC<Props> = ({ state, handleToggle }) => {
  return (
    <div>
      <h1>Sample Component</h1>
      <div>state: {state}</div>
      <button onClick={handleToggle}>toggle</button>
    </div>
  );
};
Global state
複数のコンポーネントで使用するstateはApollo Clientが提供するReactive variablesを使用します。
ヘルパー関数
まずヘルパー関数を作成、Reactive Variblesの依存関係を二つの関数に閉じ込めます。
ReactiveVarを作成する関数
Apollo Clientが提供するmakeVarを使用
import { makeVar, ReactiveVar } from '@apollo/client';
export const createReactiveVar = <T>(initialValue: T): ReactiveVar<T> => makeVar<T>(initialValue);
useStateライクにreactive variablesを使用できるカスタムフックを作成する関数
Apollo Clientが提供するuseReactiveVarを使用
※typePoliciesを使用している例ばかりなので、この実装方法に大きな不安を感じている。
// apollo/useReactiveVarHooks.ts
import { ReactiveVar, useReactiveVar } from '@apollo/client';
export type ReactiveVarHooks<T> = [T, (payload: T) => void];
export const useReactiveVarHooks = <T>(reactiveVar: ReactiveVar<T>): ReactiveVarHooks<T> => {
  const value = useReactiveVar(reactiveVar);
  const setValue = (payload: T) => {
    reactiveVar(payload);
  };
  return [value, setValue];
};
定義
// apollo/variables/isLoggedInVar.ts
import { createReactiveVar } from '../createReactiveVar';
import { ReactiveVarHooks, useReactiveVarHooks } from '../useReactiveVarHooks';
const initialValue = false;
// 変数名の接尾辞に Var を付与
export const isLoggedInVar = createReactiveVar(initialValue);
// 変数の値と変数を更新する関数を返すカスタムフック
export const useIsLoggedIn = (): ReactiveVarHooks<boolean> => useReactiveVarHooks(isLoggedInVar);
使用
値の参照
変数を使用する場合はカスタムフックの返り値の一つ目のみを受け取る
// useHooks.ts
import { useIsLoggedIn } from "@/apollo/variables/isLoggedInVar";
export const useHooks = () => {
  const [isLoggedIn] = useIsLoggedIn();
  return { isLoggedIn };
}
値の変更
関数を使用する場合はカスタムフックの返り値の二つ目のみを受け取る
// useHooks.ts
import { useIsLoggedIn } from "@/apollo/variables/isLoggedInVar";
export const useHooks = () => {
  const [, setIsLoggedIn] = useIsLoggedIn()
  const login = () => {
    setIsLoggedIn(true);
  }
  const logout = () => {
    setIsLoggedIn(false);
  }
  return { login, logout };
}
Remote state
APIから取得してくるデータを格納するstateはApollo Clientが提供するuseQueryを使用します。
import { gql, useQuery } from "@apollo/client";
import type { VFC } from "react";
import { Presenter } from "./presenter";
const GET_ALL_TODOS = gql`
  query GetAllTodos {
    todos {
      id
      title
    }
  }
`;
export const TodoList: VFC = () => {
  const { data } = useQuery<{ todos: TodoItem[] }>(GET_ALL_TODOS);
  if(!data) return null;
  return <Presenter todos={data.todos} />
}
import type { VFC } from "react";
export type Props = {
  todos: TodoItem[];
};
export const Presenter: VFC<Props> = ({ todos }) => {
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
};
Meta state
APIでデータを取得する際に発生するloadingやerrorといったstateはuseQuery, useMutationの返り値であるloading, errorを使用します。
useQuery
import { gql, useQuery } from "@apollo/client";
import type { VFC } from "react";
import { Presenter } from "./presenter";
const GET_ALL_TODOS = gql`
  query GetAllTodos {
    todos {
      id
      title
    }
  }
`;
export const TodoList: VFC = () => {
  const { data, loading, error } = useQuery<{ todos: TodoItem[] }>(GET_ALL_TODOS);
  if(loading) return <Loading />
  if(error) return <ErrorDialog error={error} />
  if(!data) return null;
  return <Presenter todos={data.todos} />
}
useMutation
import { gql, useMutation } from "@apollo/client";
import type { VFC } from "react";
import { Presenter } from "./presenter";
const ADD_TODO = gql`
  mutation AddTodo($text: String!) {
    addTodo(text: $text) {
      id
      text
    }
  }
`;
export const TodoForm: VFC = () => {
  const [addTodo, { loading, error }] = useMutation(ADD_TODO);
  if(loading) return <Loading />
  if(error) return <ErrorDialog error={error} />
  return <Presenter addTodo={addTodo} />
}
Router state
import { useRouter } from 'next/router'
import type { VFC } from "react";
export const Sample: VFC = () => {
  const router = useRouter();
  // URL が <https://localhost:3000/todo/hogefuga123> の時
  const path = router.asPath;
  // '/todo/hogefuga123'
  const query = router.query;
  // { id: 'hogefuga123' }
  return null;
};
おわりに
いかがでしたでしょうか。
使ってみるとわかりますが、Apollo Clientめっちゃ良いんですよ。ぜひ多くの方に使ってもらいたいと思う技術です!
Remote stateとMeta stateで紹介した実装方法ですが、GraphQL Code Generatorを使ってuseQueryやuseMutationをラップしたカスタムフックを自動生成できるので、実案件ではこっちを使った方が良いかもです。
GraphQL Summitの動画もアップされたので引き続きApollo Clientの理解を深めていきたいと思います。
最後まで読んでいただきありがとうございました!
Discussion