Apollo ClientはReactで使える状態管理ライブラリです。ローカルとリモートのデータをGraphQLで扱えます。本稿が解説するのは、GraphQLのクエリは用いず、ローカルの状態を複数コンポーネントの間でどう共有するかです。具体的には、リアクティブな変数(「Reactive variables」)の使い方になります(「Local State Management with Reactive Variables」参照)。
Apollo Clientは、データを扱う技術としてGraphQLと並んで注目されています。「データ層(Data Layer)」のグラフは、JavaScriptに興味がある世界中のIT技術者約2万4000人に向けて、2020年にアンケートを集めた結果です。利用度と認知度こそ定番Reduxに及ばないものの、満足度や興味はGraphQLにつぐ2位につけています。前述のとおり、本稿はGraphQLには触れません。けれど、合わせて使うなら最強のライブラリでしょう。
「データ層」2020年アンケート結果
技術 | 満足度 | 興味 | 利用度 | 認知度 |
---|---|---|---|---|
GraphQL | 1位(94%) | 1位(87%) | 2位(47%) | 1位(98%) |
Apollo Client | 2位(88%) | 2位(75%) | 3位(33%) | 3位(75%) |
Redux | 5位(67%) | 4位(55%) | 1位(67%) | 2位(97%) |
useState
で状態をもつ簡単なカウンター作例
まずは、Apollo Clientは用いることなく、useState
で状態をもつ簡単なカウンターの作例です(コード001)。
ルートモジュール(src/App.tsx
)はカウンター表示のコンポーネント(CounterDisplay
)を読み込んでアプリケーションのページに加えます。コンポーネントのJSXに差し込まれているのは加算・減算のボタンと数値の表示要素です。カウンターの数値をもつ状態変数(count
)とその増減のための関数(setCounter
)は、このあとに定めるカスタムフック(useCounter
)から受け取っています。増減の関数はボタンに加えたonClick
ハンドラに与えました。
useState
フックで状態変数(count
)とその設定関数(setCount
)を保持するのが、カスタムフックのモジュール(src/useCounter.ts
)です。カウンター数値増減の関数(setCounter
)は、引数に受け取った数値をカウンターの現在値に足し込みます。フックからは、状態変数とカウンター増減の関数をオブジェクトに収めて返しました。
コード001■ボタンで数値を増減するカウンター
import { CounterDisplay } from './CounterDisplay';
import './App.css';
function App() {
return (
<div className="App">
<CounterDisplay />
</div>
);
}
export default App;
import { FC } from 'react';
import { useCounter } from './useCounter';
export const CounterDisplay: FC = () => {
const { count, setCounter } = useCounter();
return (
<div>
<button onClick={() => setCounter(-1)}>-</button>
<span>{count}</span>
<button onClick={() => setCounter(1)}>+</button>
</div>
);
};
import { useCallback, useState } from 'react';
export const useCounter = () => {
const [count, setCount] = useState(0);
const setCounter = useCallback(
(step: number) => setCount(count + step),
[count]
);
return { count, setCounter };
};
これで、useState
フックを使った簡単なカウンターのでき上がりです。各モジュールのコードと動きは、CodeSandboxに公開したサンプル001でお確かめください。なお、関数コンポーネントのTypeScrptによる型づけは、v18からはReact.FunctionComponent
(React.FC
)を用いることになりました。React.VoidFunctionComponent
(React.VFC
)は推奨されません(「関数コンポーネント」参照)。
サンプル001■React + TypeScript: Using reactive variables of Apollo Client 01
ボタンと数値表示をモジュール分けする
もっとも、今回のお題は複数コンポーネント間で、ひとつの状態をどう扱うかです。コンポーネントは分けましょう。まず、カウンターの数値を表示するコンポーネント(CounterNumber
)です。つぎに、カウンター増減のボタン(CounterButton
)は、加算と減算のふたつで使い回します。それらをカウンター表示のモジュール(src/CounterDisplay.tsx
)が読み込んで、JSXに差し込むというわけです。
ただし、まだApollo Clientは使いません。この場合、親コンポーネント(CounterDisplay
)がカスタムフック(useCounter
)から受け取った状態変数(count
)と数値増減の関数(setCounter
)は、子コンポーネントにプロパティとして渡すのがReactではお約束です。また、ボタン(CounterButton
)は使い回すので、増減する数値(step
)とボタンのテキスト(text
)もプロパティに加えます。
import { CounterNumber } from './CounterNumber';
import { CounterButton } from './CounterButton';
export const CounterDisplay: FC = () => {
return (
<div>
{/* <button onClick={() => setCounter(-1)}>-</button> */}
<CounterButton setCounter={setCounter} step={-1} text="-" />
{/* <span>{count}</span> */}
<CounterNumber count={count} />
{/* <button onClick={() => setCounter(1)}>+</button> */}
<CounterButton setCounter={setCounter} step={1} text="+" />
</div>
);
};
そして、子コンポーネントは親からプロパティとして受け取った変数(count
)や関数(setCounter
)などを、JSXに組み込むのです。
import { FC } from 'react';
type Props = {
count: number;
};
export const CounterNumber: FC<Props> = ({ count }) => {
return <span>{count}</span>;
};
import { FC } from 'react';
type Props = {
setCounter: (step: number) => void;
step: number;
text: string;
};
export const CounterButton: FC<Props> = ({ setCounter, step, text }) => {
return <button onClick={() => setCounter(step)}>{text}</button>;
};
これが、標準Reactの作法にしたがった、コンポーネント間での状態の受け渡しです。はじめの作例と同じく、カウンターの値は正しく増減できます。細かいコードや動きは、CodeSandboxに上げたサンプル002でご確認ください。
サンプル002■React + TypeScript: Using reactive variables of Apollo Client 02
各コンポーネントからカスタムフックを呼び出そうとすると
ここから、Apollo Clientの導入です。親モジュール(src/CounterDisplay.tsx
)からカスタムフックの呼び出しを外します。つまり、もはや状態をもちません。ないものは子コンポーネントにも渡せないことになります。
// import { useCounter } from './useCounter';
import { CounterNumber } from './CounterNumber';
import { CounterButton } from './CounterButton';
export const CounterDisplay: FC = () => {
// const { count, setCounter } = useCounter();
return (
<div>
{/* <button onClick={() => setCounter(-1)}>-</button> */}
<CounterButton step={-1} text="-" />
{/* <span>{count}</span> */}
<CounterNumber />
{/* <button onClick={() => setCounter(1)}>+</button> */}
<CounterButton step={+1} text="+" />
</div>
);
};
カスタムフック(useCounter
)は、子コンポーネントがそれぞれ呼び出して、状態を取得・設定しようというのです。
import { useCounter } from './useCounter';
/* type Props = {
count: number;
}; */
// export const CounterNumber: FC<Props> = ({ count }) => {
export const CounterNumber: FC = () => {
const { count } = useCounter();
};
import { useCounter } from './useCounter';
type Props = {
// setCounter: (step: number) => void;
};
// export const CounterButton: FC<Props> = ({ setCounter, step, text }) => {
export const CounterButton: FC<Props> = ({ step, text }) => {
const { setCounter } = useCounter();
};
もちろん、これではカウンターは正しく動きません(コード002)。カスタムフック(useCounter
)から得た状態は、呼び出したコンポーネントごとに閉じているからです。加算や減算のボタンをクリックしても、それぞれのボタンの状態変数(count
)が増減するだけで、カウンターの数値表示の状態は変わりません。
コード002■子コンポーネントがそれぞれカスタムフックを呼び出す
import { FC } from 'react';
import { useCounter } from './useCounter';
export const CounterNumber: FC = () => {
const { count } = useCounter();
return <span>{count}</span>;
};
import { FC } from 'react';
import { useCounter } from './useCounter';
type Props = {
step: number;
text: string;
};
export const CounterButton: FC<Props> = ({ step, text }) => {
const { setCounter } = useCounter();
return <button onClick={() => setCounter(step)}>{text}</button>;
};
import { FC } from 'react';
import { CounterNumber } from './CounterNumber';
import { CounterButton } from './CounterButton';
export const CounterDisplay: FC = () => {
return (
<div>
<CounterButton step={-1} text="-" />
<CounterNumber />
<CounterButton step={1} text="+" />
</div>
);
};
Apollo Clientのリアクティブな変数で状態を扱う
前掲コード002の3つのモジュールのコードには手を触れません。カスタムフックのモジュール(src/useCounter.ts
)にApollo Clientを組み込みます。ここで、Apollo Client(@apollo/client
)をインストールしましょう。今回、GraphQL(graphql
)のクエリは使わないものの、依存があるのでインストールに加えてください。
npm install @apollo/client graphql
Apollo Clientでは、アプリケーションで共有する状態を、ひとつひとつリアクティブな変数(Reactive variable)として定めます(「Announcing the Release of Apollo Client 3.0」の「Reactive variables」参照)。変数をつくるのは、文字どおりmakeVar
というメソッドです。引数には初期値を与えます(「Initialize reactive variables」参照)。そして、返されるのはリアクティブな変数値を設定するための関数です。
const 設定関数 = makeVar(初期値);
その設定関数に引数として新たな値を渡して呼び出せば、リアクティブな変数値が改められます(引数を省くと、返されるのは変数の現行値です)。
そして、値が保持されるリアクティブな状態変数の参照は、コンポーネントからuseReactiveVar
フックを呼び出して得られます(「Reacting」参照)。フックの引数に渡すのはmakeVar
から返された関数です。
const リアクティブな状態変数 = useReactiveVar(設定関数);
カスタムフックのモジュール(src/useCounter.ts
)は、つぎのように書き改めます。大事なのは、フック(useCounter
)の外でmakeVar
がリアクティブな変数を初期化していることです。つまり、フックがどこから呼び出されようと、モジュールが参照するリアクティブな変数はひとつになります。コンポーネント間で変数の参照が共有化されるのです。フックが返すのは、状態変数(count
)とカウンターの設定関数(setCounter
)のまま、変わりありません。
// import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import { makeVar, useReactiveVar } from '@apollo/client';
const countVar = makeVar(0);
export const useCounter = () => {
// const [count, setCount] = useState(0);
const count = useReactiveVar(countVar);
// const setCounter = useCallback((step: number) => setCount(count + step), [
const setCounter = useCallback((step: number) => countVar(count + step), [
count
]);
};
書き直したカスタムフックのモジュール(src/useCounter.ts
)の記述全体は、つぎのコード003のとおりです。これで、前掲コード002のモジュールが、カウンターとして正しく動くようになります。CodeSandboxに掲げたのが以下のサンプル003です。
コード003■Apollo Clientのリアクティブな変数に状態をもたせたカスタムフック
import { useCallback } from 'react';
import { makeVar, useReactiveVar } from '@apollo/client';
const countVar = makeVar(0);
export const useCounter = () => {
const count = useReactiveVar(countVar);
const setCounter = useCallback((step: number) => countVar(count + step), [
count
]);
return { count, setCounter };
};
サンプル003■React + TypeScript: Using reactive variables of Apollo Client 03
なお、CodeSandboxでは、graphql
のバージョンを16以上にすると、つぎのようなTypeErrorが出てしまうようです。お気をつけください。
Cannot read properties of undefined (reading 'QUERY')
リアクティブな変数を汎用的なカスタムフックでつくる
Apollo Clientのリアクティブな変数は、Reduxのように状態をひとまとめにするのでなく、それぞれ個別に管理できます。反面、変数の数だけモジュールが増えてしまうのは悩みどころです。統一的な扱いができるように、汎用的なカスタムフックを定めました(コード004)。
モジュールsrc/useReactiveVarHooks.ts
のカスタムフックuseReactiveVarHooks
は、引数としてmakeVar
の返す設定関数(reactiveVar
)を受け取ります。そして、useReactiveVar
フックでリアクティブな変数の参照(value
)を得たうえで、フックの戻り値はその参照と設定関数に値を渡す関数(setValue
)の配列です。変数にどのような値も定められるように、TypeScriptの型づけはジェネリックを用いた少し面倒な記述になりました。
リアクティブな変数のモジュール(src/countVar.ts
)は、まずmakeVar
で値を初期化し、共有値として保持します。そのうえで、モジュールのカスタムフック(useCountVar
)が返すのは、汎用的なフック(useReactiveVarHooks
)に設定関数(countVar
)を渡した戻り値の配列そのままです。
コード004■汎用的なカスタムフックとリアクティブな変数のモジュール
import { ReactiveVar, useReactiveVar } from '@apollo/client';
import { useCallback, useMemo } from 'react';
export type ReactiveVarHooks<T> = [T, (payload: T) => void];
export const useReactiveVarHooks = <T>(
reactiveVar: ReactiveVar<T>
): ReactiveVarHooks<T> => {
const value = useReactiveVar(reactiveVar);
const setValue = useCallback(
(payload) => {
reactiveVar(payload);
},
[reactiveVar]
);
return useMemo(() => [value, setValue]
, [setValue, value]);
};
import { makeVar } from '@apollo/client';
import { ReactiveVarHooks, useReactiveVarHooks } from './useReactiveVarHooks';
const initialValue = 0;
const countVar = makeVar(initialValue);
export const useCountVar = (): ReactiveVarHooks<number> =>
useReactiveVarHooks(countVar);
すると、リアクティブな変数を用いるモジュール(src/useCounter.ts
)は、変数のフック(useCountVar
)をimport
して呼び出します。受け取るのは、変数の参照(count
)と設定関数(setCount
)を要素とする配列です。この構文がReactの組み込みフックuseState
と同じであることにご注目ください。つまり、useState
と同じように使いつつ、しかも状態変数がモジュール間で共有できるということです。
// import { makeVar, useReactiveVar } from '@apollo/client';
import { useCountVar } from './countVar';
// const countVar = makeVar(0);
export const useCounter = () => {
// const count = useReactiveVar(countVar);
const [count, setCount] = useCountVar();
// const setCounter = useCallback((step: number) => countVar(count + step), [
const setCounter = useCallback((step: number) => setCount(count + step), [
count,
setCount
]);
return { count, setCounter };
};
リアクティブな変数を用いるモジュール(src/useCounter.ts
)の書き改めた記述は、以下のコード005のとおりです。各モジュールのコードや動きは、CodeSandboxに掲げたサンプル004でお確かめください。
Apollo Clientを使うと、Reduxのように大掛かりになることなく、状態が変数ごとに切り分けられ、しかもモジュール間で共有できます。とくに、GraphQLも使う場合には、イチ推しのライブラリです(GraphQLの使い方については、「React + TypeScript: Apollo ClientのGraphQLクエリを使ってみる」をご参照ください)。
コード005■リアクティブな変数のフックから汎用的なフックの戻り値を使う
import { useCallback } from 'react';
import { useCountVar } from './countVar';
export const useCounter = () => {
const [count, setCount] = useCountVar();
const setCounter = useCallback((step: number) => setCount(count + step), [
count,
setCount
]);
return { count, setCounter };
};
サンプル004■React + TypeScript: Using reactive variables of Apollo Client 04