巷で話題の新しい状態管理ライブラリ "unreduxed" を試す!
unreduxed
リリースされたばかりの unreduxed
が話題ですね! react-redux
と unstated-next
に影響を受けた API になっており、非常にシンプルで扱いやすいライブラリになっているようです。
今回はそんな unreduxed
の使い方を記していきます!
特徴
- シンプルな API
非常にシンプルな API が特徴になっているそうです。react hooks と react context の理解さえあればすぐにライブラリを使用することができます。
API は unstated-next
の createContainer
をベースに、react-redux
の useSelector
の考えを取り込んでいます。
- 余分な再レンダリングを抑制する
ベースになっている unstated-next
は unreduxed
同様に hooks と context の理解だけでステート共有が可能なります。
しかし問題点として、context の機能をそのまま利用しているため、購読側コンポーネントの関心がないステートの変化まで検知して再レンダリングされてしまうことが挙げられます。
小さなアプリやステートの変化が少ないアプリでは unstated-next
でグローバルステートを管理してもそれほど問題にはなりませんが、アプリの規模が大きくなっていくにつれてパフォーマンスが悪くなっていきます。
redux
はアプリ全体でただひとつのステートを持ちますが、react-redux
の useSelector
がうまく値をキャッシュして余分な再レンダリングを抑制します。ただし学習コストが高いと言われています。
unreduxed
は unstated-next
の非常にシンプルな考えに react-redux
の useSelector
を持ち込みました。
使い方
何はともあれ、使ってみましょう!
インストール
npm install unreduxed
コンテナフックを作成する
unreduxed
でいうコンテナフックとは複数コンポーネント間で共有したいステートを宣言するための、値を返却するカスタムフックのことのようです。
よくある数字をカウントするだけのカスタムフックで試していきます。
import React from "react";
import unreduxed from "unreduxed";
const useCounter = (init?: number) => {
const [count, setCount] = React.useState(init ?? 0);
const increment = React.useCallback(() => setCount((prev) => prev + 1), []);
const decrement = React.useCallback(() => setCount((prev) => prev - 1), []);
return { count, increment, decrement };
};
export const [ContainerProvider, useContainer] = unreduxed(useCounter);
ライブラリから default export
される関数にコンテナフックを突っ込むだけですね。
コンテナフックには Provider
経由で初期値を渡すことも可能です(ただし初期値を渡すことは任意なので undefined
を考慮する必要があります)。ここでもコンテナフックの引数から初期値 init
を受け取るように作ります。
ContainerProvider
を配置する
上で取得された ContainerProvider
(変数名はなんでもいい) を共有したいコンポーネントツリーのトップに配置します。
ContainerProvider
に囲われたコンポーネント (ここでは <Count />
と <Buttons />
) は useContainer
が使用可能になります。
初期値は initialState
という props
で渡します。 Provider
のネストももちろん可能です。
export default function App() {
return (
<ContainerProvider>
<Count />
<Buttons />
<ContainerProvider initialState={100}>
<Count />
<Buttons />
</ContainerProvider>
</ContainerProvider>
);
}
これは unstated-next
と同じ、というより React.createContext
をベースにしているいろんなライブラリと同じ書き方なのでわかりやすいですね。useContainer
が React.useContext
のラッパーであることも想像がつきます。
useContainer
でコンテナから値を取り出す
ContainerProvider
の内側のコンポーネントで useContainer
を使用します。
useContainer
には selector
関数が渡せて、値の絞り込みが可能になっています。これが react-redux
の useSelector
を持ち込んだと述べている点になりますね。
const getRandomNum = () => Math.floor(Math.random() * 255);
const getColor = () => `rgb(${getRandomNum()},${getRandomNum()},${getRandomNum()})`;
const Count = () => {
const count = useContainer((container) => container.count);
const style = { color: getColor() };
return <p style={style}>{count}</p>;
};
const Buttons = () => {
const increment = useContainer((container) => container.increment);
const decrement = useContainer((container) => container.decrement);
const style = { color: getColor() };
return (
<div>
<button onClick={increment} style={style}>
increment
</button>
<button onClick={decrement} style={style}>
decrement
</button>
</div>
);
};
余分な再レンダリングを抑制するのが特徴とのことなので、再レンダリングを視覚的に確認できるようにランダムに文字色を変える動作を仕込んでいます。これについては こちらのブログ記事 を参考にしました。
動かしてみる
ここまでのソースコードを含んだデモアプリがこちらになります。
Buttons
コンポーネント内で useContainer
を使っているのに、各ボタンをクリックしてステートが変化しても Buttons
自体の色は変わりません!確かに余分な再レンダリングが抑制されているようです。
モックコンテナを渡すこともできる
ContainerProvider
は mock
というコンテナと同じ型の props
を受け取れるようになっています。 mock
を渡すとコンテナフックは実行されなくなり、代わりに mock
が配信されることになります。
const MockProvider: React.FC = props => {
const mock = {
count: 500,
increment: () => console.log("increment!"),
decrement: () => console.log("decrement!"),
}
return (
<ContainerProvider mock={mock}>
{props.children}
</ContainerProvider>
)
}
これは Storybook など見た目を確認するツールで利用されることを想定されています。ロジックが停止されて固定された値が常に取得できれば見た目の確認のみに集中することができますね。
まとめ
unreduxed
を非常に簡単にグローバルなステートを宣言できるようになりますね。しかも各コンポーネントで使用したい値だけを取り出せば、余分な再レンダリングは抑制してくれるためパフォーマンスの心配もなさそうです。
よければみなさんもぜひ使ってみてください!
実は
記事の冒頭に「話題に」と言われていますがきっと読者の方は初めて耳にするライブラリだという人ばかりでしょう。
それもそのはずで、これは私が先日作成したステート管理ライブラリだからです。自分のライブラリの紹介記事でした。
そこでライブラリの中身について少し書き記していきます。
余分な再レンダリングを抑制する仕組み
context に愚直にステートを突っ込んで useContext
で拾い上げる unstated-next
パターンは、ステートが更新されたときに useContext
しているコンポーネントすべてが再レンダリングされてしまいます。
useContext
で再レンダリングされない条件は context の中身が同じ参照を持ち続けていることです。ただ、 useContext
で取得するオブジェクトが不変だと購読側コンポーネントは変わってほしいときにも再レンダリングされなくなってしまいます。そこで思いついたのが、購読側コンポーネントが Provider から値を受け取るのではなく、購読側コンポーネントを再レンダリングさせる関数を Provider に集約することでした。
unreduxed の内部イメージ
上の内部イメージにおいて、notifier
はクラスインスタンスとして宣言されており、それを Provider
内部で React.useRef
で保持しています。
context
で配信するのは notifier
インスタンスなので、参照が変更されることはありません。そのため、useContext
による再レンダリングは発生しません。
notifier
インスタンスは useContainer
によって購読側コンポーネントまで下っていき、そこで購読側コンポーネントを再レンダリングするための listener 関数を集めます。
listener 関数ひとつひとつはコンテナを受け取り selector
に渡して次の値を評価します。useContainer
は useRef
で前回値を保持しているので、次の値と比較して変更されていれば ref
を更新して新しい値を返します。
ContainerProvider
内では、コンテナの変化を検知する useEffect
によってすべての listener
を発火します。
React hooks 時代にクラスインスタンスかよと思われるかもしれませんが、私もそう思うので API はまったくそれを感じさせないために unstated-next
を踏襲した関数型のインターフェイスにしました(作ってから別にクラスインスタンスじゃなくてもできそうって思った)。ライブラリを使用する側は何も意識することなくただカスタムフックを組み合わせることでステートの共有が可能となります。学習コストもほぼゼロでしょう。
つらつらと日本語でしゃべるよりもプログラム言語で読んだほうが理解が早いと思うので興味のある方はこちらでご確認ください。
今後について
React v18 で Concurrent Mode という機能が搭載されると言われています。React コンポーネントで非同期処理を扱いやすくするための機能です。Promise を throw すると React が吸収してなんかいい感じにしてくれるアレです(よくわかっていない)。
unreduxed
はまったく Concurrent Mode を考慮していません。おとなしく、、、 Recoil を、、、使いましょう、、、。
終わりに
useContext
でも余計な再レンダリングしたくない!という一心でライブラリを作ってみました。
良ければ使っていただき、いいところ悪いところのフィードバックをいただけると幸いです。
特に内部実装の問題点があれば指摘していただけると助かります。
Discussion
@stin
非常にシンプルに扱えて、とても良いですね👍
インスタンスをPropsで渡すことで描画更新を抑えるアイディアは、
私には無かったので、とても参考になりました🙌
ソースコードもとても見やすかったです。
これはunstated-nextでもいえる事ですが、Providerが多くなって大変になりそうな感じがします。私としては、Providerを一つにまとめる事が出来れば嬉しいですね。
最近はRecoilなどのAtomicな状態管理が話題ですが、色々と問題点は多いと思っているので、unreduxedのように、違ったアプローチを持った状態管理が必要だと個人的に思っています。
ご覧頂きありがとうございます!
Provider
を何重にも積み上げなければならない問題は私も同じことを感じますし他の開発者の方もよく言及しているのを見かけます。現状の
unreduxed
の作りでは、コンテナフックの数だけProvider
を生成しなければなりません。コンテナフックをひとつにしてすべての共有したいステートとロジックを詰め込むことでProvider
をひとつにすることも可能ですが、責務の異なるステートやロジックをひとつにまとめることは誰も好まないでしょう😇複数のコンテナフックを登録してただひとつの
Provider
を生成するような作りにアップデートするかは現在構想中です。useContainer
のインターフェイスをどうするかを、他のライブラリも調査して丁寧に考えないといけないと思っています。ソースコードまで見ていただいて本当に嬉しいです。ありがとうございます🙇♂️