LoginSignup
2
3

More than 1 year has passed since last update.

React + TypeScript: Apollo Clientのリアクティブな変数(reactive variables)で複数コンポーネント間の状態を管理する

Posted at

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■ボタンで数値を増減するカウンター

src/App.tsx
import { CounterDisplay } from './CounterDisplay';
import './App.css';

function App() {
	return (
		<div className="App">
			<CounterDisplay />
		</div>
	);
}

export default App;
src/CounterDisplay.tsx
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>
	);
};
src/useCounter.ts
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)もプロパティに加えます。

src/CounterDisplay.tsx
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に組み込むのです。

src/CounterNumber.tsx
import { FC } from 'react';

type Props = {
	count: number;
};
export const CounterNumber: FC<Props> = ({ count }) => {
	return <span>{count}</span>;
};
src/CounterButton.tsx
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)からカスタムフックの呼び出しを外します。つまり、もはや状態をもちません。ないものは子コンポーネントにも渡せないことになります。

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)は、子コンポーネントがそれぞれ呼び出して、状態を取得・設定しようというのです。

src/CounterNumber.tsx
import { useCounter } from './useCounter';

/* type Props = {
	count: number;
}; */
// export const CounterNumber: FC<Props> = ({ count }) => {
export const CounterNumber: FC = () => {
	const { count } = useCounter();

};
src/CounterButton.tsx
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■子コンポーネントがそれぞれカスタムフックを呼び出す

src/CounterNumber.tsx
import { FC } from 'react';
import { useCounter } from './useCounter';

export const CounterNumber: FC = () => {
	const { count } = useCounter();
	return <span>{count}</span>;
};
src/CounterButton.tsx
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>;
};
src/CounterDisplay.tsx
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)のまま、変わりありません。

src/useCounter.ts
// 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のリアクティブな変数に状態をもたせたカスタムフック

src/useCounter.ts
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■汎用的なカスタムフックとリアクティブな変数のモジュール

src/useReactiveVarHooks.ts
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]);
};
src/countVar.ts
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と同じように使いつつ、しかも状態変数がモジュール間で共有できるということです。

src/useCounter.ts
// 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■リアクティブな変数のフックから汎用的なフックの戻り値を使う

src/useCounter.ts
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

2
3
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
2
3