結論を最初に書いてしまうと、unstated-nextを使わずContextAPIをラップするやり方をご紹介するのが本記事です。
unstated-nextの簡単な解説と、問題点についてもご紹介します。(問題点を解消するやり方のご紹介でもあります)
まずはContextAPIの復習から行きましょう。
ContextAPI の復習
const SomeContext = React.createContext(null);
const App = () => {
return (
<SomeContext.Provider value={123}>
<SomeConsumer />
</SomeContext.Provider>
)
}
const SomeConsumer = () => {
const value = React.useContext(SomeContext) // valueに123がはいってくる
return (
<div>{value}</div>
)
}
1つのjsxファイルに上記のように全部実装することはありませんが、復習ということで要素だけを簡単に実装しています。
中心になるのは、React.createContextメソッドの戻り値であるSomeContextインスタンスです。引数にnullを渡しているのはあまり気にしないでいいです。(引数に何を渡しても使用されることはありません)以降、このSomeContextのようにReact.createContextメソッドから得られる結果をContextインスタンス、と記載します。
Appの実装を見ると、Contextインスタンスが持つProvierメソッドを使ってSomeConsumerコンポーネントを囲っています。この囲いの中のコンポーネントであれば、value値である123を受け取れることを意味します。スコープを決めているわけです。
先に書いてしまいましたが、何を下流コンポーネントに渡すのかはProviderのプロパティであるvalueにセットします。ここでしかセットできません。
SomeConsumerコンポーネントを見てみましょう。React.useContextにContextインスタンスを渡して、valueを取り出しています。簡単ですね。
強く認識しておきたいのは、Contextインスタンスがグローバルな扱いになる必要があることです。値をセットして下流コンポーネントを囲う時にも、下流コンポーネントが値を取り出す時にもContextインスタンスを使います。だからContextインスタンスはグローバル変数なのです。ちょっと嫌な感じですね。
さてそれはさておき、とっても簡単に使えちゃうので逆にどう使っていいのか迷子になりやすいのもContextAPIの特徴と言えるでしょう。
unstated-next
unstated-nextとは、ContextAPIをラップした超軽量なミドルウェアです。ソースはなんと40行しかありません。
https://github.com/jamiebuilds/unstated-next
このunstated-nextは自由度が高すぎて扱いに困っちゃうContextAPIについて、変数を共有するのではなく、カスタムフックを共有することに特化することを提案しています。
使い方はとても簡単です。サンプルでよく見るカウントアップダウンをやってみましょう。まず、カスタムフックを作ります。
const useCounter = () => {
const [counter, setCounter] = React.useState(0);
const increment = () => { setCounter(value => value + 1) };
const decrement = () => { setCounter(value => value - 1) };
return {counter, increment, decrement};
}
次にunstated-nextのCreateContainerメソッドの引数にカスタムフックの「定義」を渡してContainerインスタンスを作ります。カスタムフックのインスタンスではなく、定義を渡しているところがポイントです。このContainerインスタンスはContextAPIのContextインスタンスに相当します。unstated-nextではContextではなくContainerという用語を使います。ややこしいですね。
import { createContainer } from 'unstated-next';
export const CounterContainer = createContainer(useCounter);
ContextAPIのProviderの使い方と同じように、下流コンポーネントを囲いますが、valueプロパティには何もセットしていません。そもそも、Containerインスタンスはvalueプロパティを持っていないのです。
const App = () => {
return (
<CounterContainer.Provider> {/* valueプロパティがない! */}
<CountUpDown />
</CounterContainer.Provider>
)
}
Consumer側も簡単です。ContextAPIとよく似た使い方ができます。
import { createContainer, useContainer } from 'unstated-next';
const CountUpDown = () => {
const counterContext = useContainer(CounterContainer);
// もしくは
// const counterContext = CounterContainer.useContainer();
return (
<div>
<span>{counterContext.counter}</span> <button onClick={counterContext.increment}>+</button> <button onClick={counterContext.decrement}>-</button>
</div>
);
}
unstated-nextが特徴的なのは、カスタムフックを使うシナリオしか想定してしないことです。またContextAPIでは、Providerのプロパティであるvalueにカスタムフックのインスタンスをセットをしますが、unstated-nextではカスタムフックのインスタンス化はunstated-nextの内部で行われます。ちょっとunstated-nextの内部を覗いてみましょう。
export function createContainer<Value, State = void>(
// 引数useHookでカスタムフックの定義を受け取って
useHook: (initialState?: State) => Value,): Container<Value, State> {
let Context = React.createContext<Value | typeof EMPTY>(EMPTY)
function Provider(props: ContainerProviderProps<State>) {
// カスタムフックをインスタンス化
let value = useHook(props.initialState)
// ContextAPIのProviderにセット
return <Context.Provider value={value}>{props.children}</Context.Provider>
}
・・・
}
unstated-nextの問題点
この内部でカスタムフックをインスタンス化する仕様が原因で大変困ることがあります。例えばこういう場合。
const App = () => {
return (
<React.Fragment>
<CounterContainer.Provider>
<CountUpDown />
</CounterContainer.Provider>
<CounterContainer.Provider>
<CountUpDown />
</CounterContainer.Provider>
{/* ここに 二つのCountUpDownコンポーネント内部のカウント結果の合算を表示したい*/}
</React.Fragment>
)
}
カウントアップダウンで保持している値を外側で利用したい、という例です。汎化した言い方でいうと、Container内部のカスタムフックで起こったイベントを外部からハンドリングしたいときや、カスタムフックの保持している値に外部からアクセスしたい場合です。
もちろん無理矢理やればできないわけじゃないです。でもとてもややこしい実装になって複雑化するので良いことありません。
ContextAPIであれば、Providerのvalueプロパティに自らインスタンス化したカスタムフックをセットするので、カスタムフックが公開している値やイベントに対してハンドリングすることができます。
export const CounterContext = React.createContext(null);
const App = () => {
// 自分でカスタムフックをインスタンス化しておいて
const counter1 = useCounter();
const counter2 = useCounter();
return (
<div>
<CounterContext.Provider value={counter1}>{/* ここでセットしている */}
<CountUpDown></CountUpDown>
</CounterContext.Provider>
<CounterContext.Provider value={counter2}>{/* ここでセットしている */}
<CountUpDown></CountUpDown>
</CounterContext.Provider>
<span>合計:{counter1.counter + counter2.counter}</span>{/* カスタムフックが公開する値を使える */}
</div>
);
}
ということは、unstated-nextがContextAPIのように外側からカスタムフックのインスタンスを渡せるように改造すればよいわけです。
じゃあ早速やってみましょう・・・と言いたいところですが、個人的にunstated-nextのContainerインスタンスもContextAPIのContextインスタンスと同じようにグローバル変数として取り回しをしなくてはいけないのがどうにも気に入らないのと、unstated-nextのメンテナンスが2019年5月から停止しているので別のアプローチで行きたいと思います。
ContextAPIをラップして便利に使う
ContextAPIの使い方の提案は世の中に色々あると思いますが、こちらのページの使い方がとても勉強になりました。
https://kentcdodds.com/blog/how-to-use-react-context-effectively
ProviderとuseContextをラップする実装方法はこちらで解説してあるやり方そのまんまです。こちらの方法でうまくラップすると、Contextインスタンスが見事に消え去ります。そしてそれを改造してunstated-next使用時に困ってしまう点を解消します。
さてまずは結果からお見せしたいと思います。ContextAPIに慣れている方は一読すればすぐわかるかと思います。(下で解説します)
import * as React from 'react';
// Contextインスタンス。exportしていないことに注目
const CounterContext = React.createContext(undefined);
// カスタムフック
export const useCounter = () => {
const [counter, setCounter] = React.useState(0);
const increment = () => { setCounter(value => value + 1) };
const decrement = () => { setCounter(value => value - 1) };
return {counter, increment, decrement};
}
// Provierの実装
export const CounterProvider = ({children, hooks}) => {
const tempHooks = useCounter();
const valueHooks = hooks || tempHooks;
return (
<CounterContext.Provider value={valueHooks}>
{children}
</CounterContext.Provider>
)
}
// Consumerの実装
export const CounterHooks = () => {
const hooks = React.useContext(CounterContext);
if (hooks === undefined){
throw new Error("CounterProviderが見つかりません。");
}
return hooks;
}
/****** 実装はここまで ******/
// Consumerの使い方はこちら。Contextオブジェクトを使っていない!
const Index = () => {
const counterHooks = CounterHooks();
return (
<div>
<span>{counterHooks.counter}</span> <button onClick={counterHooks.increment}>+</button> <button onClick={counterHooks.decrement}>-</button>
</div>
);
}
// Providerの使い方はこちら。Contextオブジェクトを使っていない!
const App = () => {
const counter1 = useCounter();
return (
<div>
<CounterProvider hooks={counter1}>
<CountUpDown></CountUpDown>
</CounterProvider>
<CounterProvider>
<CountUpDown></CountUpDown>
</CounterProvider>
<span>上の値:{counter1.counter}</span>
</div>
);
}
一つずつみてみましょう。まずはProviderの使い方からです。Contextオブジェクトを使っていません。その代わりにラップしたCounterProviderを使用しています。CounterProviderは仮引数hooksを受け付けるのですがこれはオプションです。1つ目のCounterProviderは外側からカスタムフックのインスタンスを受け取っていますが、二つ目のCounterProviderは受け取っていません。仮引数hooksにカスタムフックのインスタンスがセットされない場合は、unstated-nextと同様に自動的に内部でカスタムフックをインスタンス化します。
1つ目のCounterProviderのためにカスタムフックを自分でインスタンス化していますので、CountUpDownコンポーネント内部でカウントアップ・アップした結果の値を一番下のspanタグ内部で使うことができていますね。unstated-nextの問題点がきれいに解消されました。
// Providerの使い方はこちら。Contextオブジェクトを使っていない!
const App = () => {
const counter1 = useCounter();
return (
<div>
<CounterProvider hooks={counter1}>
<CountUpDown></CountUpDown>
</CounterProvider>
<CounterProvider>
<CountUpDown></CountUpDown>
</CounterProvider>
<span>上の値:{counter1.counter}</span>
</div>
);
}
次はCounterProviderの実装です。戻り値にはContextインスタンスのProviderメソッドの結果を使用しています。通常のContextAPIの使い方ですね。そしてProviderメソッドのvalueプロパティには引数hooksにカスタムフックのインスタンスを渡されたらそれを使用し、渡されなかったら自分でインスタンス化したカスタムフックを使用しています。unstated-nextの時の問題点をこの実装で解消していることがわかりいただけるでしょうか。
ちなみに、これ1行で書けそうだよね?と思ったと思いますが、カスタムフックと演算子を同時に使うとreactに「そんなことしちゃダメ!」って怒られたのでわざわざ二行で実装しています。
// Provierの実装
export const CounterProvider = ({children, hooks}) => {
const tempHooks = useCounter();
const valueHooks = hooks || tempHooks;
return (
<CounterContext.Provider value={valueHooks}>
{children}
</CounterContext.Provider>
)
}
次はConsumerの使い方です。こちらもContextオブジェクトを使わず、メソッドを叩いてカスタムフックのインスタンスを受け取っています。とてもシンプルです。
// Consumerの使い方はこちら。Contextオブジェクトを使っていない!
const Index = () => {
const counterHooks = CounterHooks();
return (
<div>
<span>{counterHooks.counter}</span> <button onClick={counterHooks.increment}>+</button> <button onClick={counterHooks.decrement}>-</button>
</div>
);
}
Consumerの実装です。ここで予めContextオブジェクトからカスタムフックのインスタンスを受け取り、それを返却しているだけです。
export const CounterHooks = () => {
const hooks = React.useContext(CounterContext);
if (hooks === undefined){
throw new Error("CounterProviderが見つかりません。");
}
return hooks;
}
いかがでしょうか。データの共用はカスタムフックで行うべし、というunstated-nextの方針は大賛成なのですが、使いにくいところをこれで解消することができました。また、Contextインスタンスへの依存も無くなって使う側はとてもわかりやすくなったと思います。
でも、ContextAPIで何かを共有するたびにこれを作るのかよー!と思わなくもないですね。便利なミドルウェアにできればいいのになーと思わなくもないです。実装がunstated-nextによく似たものになりそうですけど。
最後に、TypeScript版を載せておきます。Provider, Consumerの使い方は上記jsx版と全く同じです。
import * as React from 'react';
type hooksValue = {
counter: number,
increment: () => void,
decrement: () => void
}
type ProviderProps = {
children: React.ReactNode,
hooks?: hooksValue | undefined
}
const CounterContext = React.createContext<hooksValue | undefined>(undefined);
export const useCounter = (): hooksValue => {
const [counter, setCounter] = React.useState(0);
const increment = () => { setCounter(value => value + 1) };
const decrement = () => { setCounter(value => value - 1) };
return {counter, increment, decrement};
}
export const CounterProvider = ({children, hooks}: ProviderProps) => {
const tempHooks = useCounter();
const valueHooks = hooks || tempHooks;
return (
<CounterContext.Provider value={valueHooks}>
{children}
</CounterContext.Provider>
)
}
export const CounterHooks = () => {
const hooks = React.useContext(CounterContext);
if (hooks === undefined){
throw new Error("CounterProviderが見つかりません。");
}
return hooks;
}