はじめに
Reactで開発を行う際、useEffectの中でstate更新を行うのは、アンチパターンである可能性を疑うべきポイントとして知られています。これらの組み合わせは、予期せぬ挙動や、コードベースの認知負荷の増大に繋がりやすいのも事実です。
詳しくはReact公式ドキュメントのYou Might Not Need an Effectを参照ください。
そのようなuseEffectの中でのstate更新を「蝶の舞」として可視化するViteプラグインを作ってみました。本記事でその紹介をします。
作ったもの
こちらから実際に動作を確認できます。
Effectがチェインするデモを作ってみたので、ボタンをクリックしてみてください。
コンセプト
本プラグインは、カオス理論の「バタフライエフェクト」をメタファーとしています。
蝶の羽ばたきのようなほんのわずかな変化が想像もしないほどの大きなことに繋がる
useEffect内のsetStateは、まさにこの「小さな変化」に相当すると考えました。
その小さな変化によって別のuseEffectのコールバック処理が実行され、さらに別のstate更新を呼び...と連鎖していくことで、コードベースは次第に予測困難になっていきます。
具体的には、以下のような問題を招きます。
- 構造的な複雑化:依存関係が絡み合い、コードの見通しが悪くなる
- 認知的な負荷:何がどのタイミングで更新されるのか追いにくくなる
- バグの混入:無限ループやstate更新の連鎖、予期しない再レンダリングを招く
本プラグインは、この「小さな変化」を蝶の舞として可視化します。
蝶が多く舞うほどコードがカオスに近づいているサインであり、開発者への早期警告となることを目指しています。
仕組み
Viteのカスタムプラグインを通じて、バンドル前にコードの変換が可能です。
本プラグインはこの仕組みを利用し、Reactコードを解析してトラッキング用のコードを挿入します。動作は「ビルド時」と「実行時」の2段階に分かれます。
本記事ではプラグイン自体の実装方法の詳細には踏み込みませんが、以下のドキュメントが参考になると思います。
ビルド時の処理
開発者が書いたReactコードは、ブラウザで実行される前にViteによってバンドルされます。
このバンドル処理に介入し、ソースコードをAST(抽象構文木)として解析します。
ASTとはソースコードを構文的な構造として表現したツリー形式のデータであり、プラグインはこのASTを走査し、useStateとuseEffectの呼び出しを検出します。
検出したら、それぞれを追跡用の関数でラップするようにASTを書き換え、変換済みのコードを出力します。どのsetterがどのuseEffect内から呼ばれたかがトラッキング可能になります。
実装詳細(useStateのsetterの変換)
/**
* useStateのsetterをトラッキングコードでラップする
*
* 変換前:
* const [count, setCount] = useState(0);
*
* 変換後:
* const [count, __butterfly_original_setCount] = useState(0);
* const setCount = __wrapSetter(__butterfly_original_setCount, "App", 11);
*
* __wrapSetter は WeakMap でキャッシュさせ、setter の参照を安定させる
*/
const wrapUseStateSetter = (
callPath: NodePath<t.CallExpression>,
componentName: string,
): SetterInfo | null => {
const parent = callPath.parent;
// const [state, setState] = useState(...) の形式である必要がある
if (!t.isVariableDeclarator(parent)) return null;
if (!t.isArrayPattern(parent.id)) return null;
if (parent.id.elements.length < 2) return null;
const setterElement = parent.id.elements[1];
if (!t.isIdentifier(setterElement)) return null;
const setterName = setterElement.name;
const originalSetterName = `__butterfly_original_${setterName}`;
const line = callPath.node.loc?.start.line || 0;
// 1. 分割代入パターン内のsetterをリネーム
parent.id.elements[1] = t.identifier(originalSetterName);
// 2. __wrapSetter を使ってラップされたsetterを作成
// const setCount = __wrapSetter(__butterfly_original_setCount, "App", 11);
const wrappedSetter = t.variableDeclaration("const", [
t.variableDeclarator(
t.identifier(setterName),
t.callExpression(t.identifier("__wrapSetter"), [
t.identifier(originalSetterName),
t.stringLiteral(componentName),
t.numericLiteral(line),
]),
),
]);
// 3. useState宣言の直後にラップされたsetterを挿入
const variableDeclarationPath = callPath.findParent((p) =>
p.isVariableDeclaration(),
) as NodePath<t.VariableDeclaration> | null;
if (variableDeclarationPath) {
variableDeclarationPath.insertAfter(wrappedSetter);
}
return {
name: setterName,
originalName: originalSetterName,
line,
};
};
実装詳細(useEffectのcallbackの変換)
/**
* useEffectコールバックを__wrapEffectでラップ
*
* 変換前:
* useEffect(() => {
* setCount(1);
* }, []);
*
* 変換後:
* useEffect(__wrapEffect("Effect_App_Line5", () => {
* const __butterfly_effectId = "Effect_App_Line5";
* const __bound_setCount = __v => setCount(__v, __butterfly_effectId);
* __bound_setCount(1);
* }), []);
*
*/
const wrapUseEffectCallback = (
callPath: NodePath<t.CallExpression>,
componentName: string,
setterInfos: SetterInfo[],
): boolean => {
const callback = callPath.node.arguments[0];
if (
!t.isArrowFunctionExpression(callback) &&
!t.isFunctionExpression(callback)
) {
return false;
}
const line = callPath.node.loc?.start.line || 0;
const effectId = `Effect_${componentName}_Line${line}`;
// effect内で使用されているsetterを検出
const usedSetters = findUsedSetters(callback, setterInfos);
// Closure Binding: setterにeffectIdをバインド
if (usedSetters.length > 0) {
// 先にsetter参照を置換してから、bound setter宣言を注入
const callbackPath = callPath.get("arguments.0") as NodePath<
t.ArrowFunctionExpression | t.FunctionExpression
>;
replaceSetterReferences(callbackPath, usedSetters);
// bound setter宣言を先頭に注入
injectEffectIdBinding(callback, effectId, usedSetters);
}
// コールバックを__wrapEffectでラップ
callPath.node.arguments[0] = t.callExpression(t.identifier("__wrapEffect"), [
t.stringLiteral(effectId),
callback,
]);
return true;
};
/**
* コールバック内にeffectIdとバインド版setterを注入
*/
const injectEffectIdBinding = (
callback: t.ArrowFunctionExpression | t.FunctionExpression,
effectId: string,
usedSetters: SetterInfo[],
) => {
const body = callback.body;
// 式をブロック文に変換
if (!t.isBlockStatement(body)) {
callback.body = t.blockStatement([t.returnStatement(body)]);
}
const blockBody = callback.body as t.BlockStatement;
// effectId定数
const effectIdDeclaration = t.variableDeclaration("const", [
t.variableDeclarator(
t.identifier("__butterfly_effectId"),
t.stringLiteral(effectId),
),
]);
// バインド版setter
const boundSetterDeclarations: t.VariableDeclaration[] = usedSetters.map(
(setter) => {
const boundName = `__bound_${setter.name}`;
return t.variableDeclaration("const", [
t.variableDeclarator(
t.identifier(boundName),
t.arrowFunctionExpression(
[t.identifier("__v")],
t.callExpression(t.identifier(setter.name), [
t.identifier("__v"),
t.identifier("__butterfly_effectId"),
]),
),
),
]);
},
);
// 先頭に挿入
blockBody.body.unshift(effectIdDeclaration, ...boundSetterDeclarations);
};
実行時の処理
変換されたコードがブラウザで実行されると、ラップした関数が動作します。
setStateが呼ばれるたびに、ラッパー関数が「現在useEffectの中で実行されているか」を判定します。useEffect内からの呼び出しであれば、イベントを発火しCanvas上に蝶を描画します。
まとめ
useEffectは、よくある誤解として「ある値が変化したら、何かを実行する」だけが先行しやすく、変更検知のツールのように扱われがちです。
「〇〇されたらXXする」という思考パターンとの親和性が高いため、気づけばあらゆる処理をuseEffectで書いてしまう、ということが起こります。
その考え方でstate更新まで行うと、更新が更新を呼び「なぜこのタイミングで動くのか」の把握が困難になります。
このプラグインが、そうなる前に立ち止まって考える機会を提供するためのツールとなれば幸いです(プラグイン自体の公開は準備中です。)
ここまで読んでいただきありがとうございました。
質問やフィードバックがあれば、ぜひコメントください。