ブログ一覧に戻る
状態管理ElectronReactリファクタアーキテクチャ

対症療法を捨てて構造で消す — Electron アプリの状態管理リファクタ

TL;DR

  • バグを 1 件ずつ patch すると別のバグが顔を出す状態でした
  • 共通の根は「state Source of Truth が複数並立し、相互に整合しない瞬間がある」ことでした
  • 対症療法の if (isLocalMode) return; を散らすのを止め、Provider 階層で物理分離 に切り替えました
  • boot シーケンスを 1 つの async 関数 に集約し、各 component の useEffect の偶発的な発火順序に依存しないようにしました
  • BootOverlay を一貫表示することで「画面のちらつき」を構造的に解消しました
  • 学びは「対症療法はかえって解析を難しくする」ということでした

背景:8 つのバグが連鎖していた

niyase desktop(Electron + Vite + React + PGlite)のリリース直前期に、ローカル / Firebase のマルチアカウント機構と onboarding/移管フローを整備していた過程で、次のようなバグが連鎖的に発生しました。

  • Firebase ID トークンキャッシュ → 403 ループ
  • AccountMenu に同一ユーザーが 2 件並ぶ
  • idb://niyase-local に cloud namespace のデータが書き込まれる漏洩
  • user-context:local に cloud スペースの UUID が残る
  • 個人スペース flag の cloud namespace への漏洩
  • ログイン中のスプラッシュ・ローディング多重表示で画面がちらつく
  • /users/me/workspaces が複数 component から重複 fetch → dev で 429 / connection refused / 401 ループ
  • React の Cannot update SidebarProvider while rendering UserContextProvider 警告

各バグを 1 件ずつ patch すると、別のバグが新たに顔を出す状態でした。「コードがバグだらけで、リファクタが必要だ」と判断したところから始めました。

診断:State Source of Truth が並立していた

調べてみると、共通の根がありました。state の起点が 複数並立 していて、相互に整合しない瞬間があるのです。

Layer名前実体変更タイミング
Layer 1registry.activeUserIdlocalStorage["niyase-accounts"] の JSONsetActiveUserId()(即時反映)
Layer 2openedAccountIdPGlite が開いた namespacedoInitMeta() で 1 回だけ(reload で再 mount)

この 2 つは 必ずしも一致しません

signup 直後の reload 前:
  registry.activeUserId = <niyaseUserId>   ← setActiveUserId 済み
  openedAccountId       = "local"          ← まだ boot 時の値

「ローカル切替」直後の reload 前:
  registry.activeUserId = "local"          ← setActiveUserId 済み
  openedAccountId       = <niyaseUserId>   ← まだ前回 boot 時の値

この 不一致の瞬間 に scoped key を書くと、「次回 reload 後の namespace」に書き込んでしまい、片方のアカウントが他方のデータで汚染される。これが過去の漏洩バグの根源でした。

解決 1:scoped key の anchor を PGlite namespace に固定

accountScopedKey(base) の anchor を openedAccountId(Layer 2)に固定:

// lib/account-storage.ts
export function accountScopedKey(base: string, accountId?: string): string {
  if (accountId !== undefined) return `${base}:${accountId}`;
  const opened = getOpenedAccountId(); // ← Layer 2 を優先
  if (opened !== null) return `${base}:${opened}`;
  return `${base}:${currentAccountId()}`; // boot 前のみ Layer 1 にフォールバック
}

これだけで user-context:<accountId> / active-workspace-id:<accountId> / personal-space-added:<accountId> などの scoped key が すべて「いま PGlite が触っているのと同じ namespace」 に書き込まれます。「ユーザーは local 切替したが PGlite はまだ cloud namespace」の瞬間に WorkspaceProvider が setWorkspace を発火しても、書き込み先は cloud namespace の scoped key になる → 漏洩しない。

レースコンディションが構造的に消えました。

解決 2:cloud API 呼び出しを Provider 階層で物理分離

「ローカル時に cloud API を呼ばないガード」を 5 つの component に散らしていた状態を止めました。Provider 階層で物理的に分離 することで、ローカル時は cloud API を呼ぶ component が そもそも mount されない 構造に。

<App>
  <CurrentAccountProvider>
    <CloudAwareGate>
      {accountKind === "firebase" ? (
        <CloudResourcesProvider>     {/* Firebase 限定。cloud API 呼び出し集約 */}
          <WorkspaceListProvider>    {/* /users/me/workspaces を 1 回だけ */}
          <SidebarPrefsProvider>     {/* /users/me/sidebar-preferences (スペース単位) */}
            <UserContextProvider>
              <WorkspaceProvider>
                <Routes>...</Routes>
              </WorkspaceProvider>
            </UserContextProvider>
          </>
        </CloudResourcesProvider>
      ) : (
        <UserContextProvider>...</UserContextProvider>
      )}
    </CloudAwareGate>
  </CurrentAccountProvider>
</App>

子 component で getOpenedAccountKind() === "firebase" のような実行時ガードを書く必要が消えました。mount されていなければ呼べないからです。

加えて、/users/me/workspacesWorkspaceListProvider1 回だけ 取得して context で配るので、WorkspaceSwitcherWorkspaceProvider の重複 fetch が構造的に消えました。

解決 3:boot シーケンスを 1 つの async 関数に集約

ログインからポータル着地までは、PGlite 初期化 → Firebase auth 復元 → sync pull → スペース DB open → portal mount と複数の処理が直列に走ります。旧設計では各 component の useEffect が偶発的な発火順序でこれを実現していて、デバッグが非常に困難でした。

bootSequence Orchestrator にまとめます:

// lib/boot-orchestrator.ts
let bootPromise: Promise<{ target: string }> | null = null;

export function bootSequence(): Promise<{ target: string }> {
  if (bootPromise) return bootPromise; // React 18 StrictMode の double invoke 対策
  bootPromise = doBootSequence();
  return bootPromise;
}

async function doBootSequence(): Promise<{ target: string }> {
  setBootPhase("auth-restore");
  const user = await waitForFirebaseAuth();

  let rows = await listLocalWorkspaces();
  if (rows.length > 0) return { target: ... };

  if (user) {
    setBootPhase("sync-pull");
    await getSyncManager().initialize(...);
    rows = await listLocalWorkspaces();
    if (rows.length > 0) return { target: ... };
  }
  return { target: await resolveNoLocalWs(!!user) };
}

呼び出し側 (RootRedirect) は 15 行 に簡素化:

function RootRedirect() {
  const [target, setTarget] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    bootSequence()
      .then(({ target }) => !cancelled && setTarget(target))
      .catch(() => setTarget("/onboarding"));
    return () => {
      cancelled = true;
    };
  }, []);
  if (!target) return null;
  return <Navigate to={target} replace />;
}

3 つの効果がありました。

  1. 順序の可読性: 「PGlite init → auth restore → sync pull → スペース resolve」が一直線
  2. 副作用の冪等化: bootPromise キャッシュで StrictMode の double-invoke を吸収(sync.initialize が 2 回走らなくなる)
  3. エラーハンドリングが集中: try/catch を 1 箇所に

解決 4:BootOverlay で画面のちらつきを構造的に解消する

旧設計では、PGlite 初期化中、auth restore 中、sync pull 中、スペース DB open 中、portal mount 中で それぞれ独立した spinner が出ていて、reload や render を跨ぐとちらつきました。

1 つの BootOverlay で全 phase を覆う設計に:

// lib/boot-phase.ts
export type BootPhase =
  | "idle"
  | "auth-signin"
  | "auth-restore"
  | "pglite-init"
  | "sync-pull"
  | "ws-open"
  | "ready";

export function setBootPhase(phase: BootPhase, reason?: string): void {
  console.log(`[Boot] phase: ${prev}${phase} | ${reason}`);
  sessionStorage.setItem("boot-phase", phase); // reload を跨いで継続
  listeners.forEach((l) => l(phase));
}

<BootOverlay> が phase を購読して ready 以外の間ずっと全画面 overlay を表示。phase 遷移時に メッセージとプログレスバーだけ差し替わる ので、画面はずっと同じ overlay で覆われ続けます。「ログインしています...」→「クラウドからデータを取得しています...」→「スペースを開いています...」と進捗が読み取れる体験になりました。

解決 5:React の pure updater ルールに準拠

旧設計の UserContextProvider.setWorkspace:

setContextState((prev) => {
  const merged = { ...prev, workspaceId: id, ... };
  persistUserContext(merged);        // ← 副作用
  setActiveWorkspaceId(id);          // ← 副作用
  window.dispatchEvent(...);          // ← render 中の dispatchEvent
  return merged;
});

これは React 19+ の pure updater ルール違反で、Cannot update SidebarProvider while rendering UserContextProvider 警告の原因。

純粋化:

const setWorkspace = useCallback((id, slug, name, role, type) => {
  setContextState((prev) => ({ ...prev, workspaceId: id, ... })); // pure
}, []);

useEffect(() => {
  if (!hydratedRef.current) { hydratedRef.current = true; return; }
  persistUserContext(context);
  if (context.workspaceId) setActiveWorkspaceId(context.workspaceId);
}, [context]);

useEffect(() => {
  if (prevWorkspaceIdRef.current === context.workspaceId) return;
  prevWorkspaceIdRef.current = context.workspaceId;
  if (context.workspaceId) window.dispatchEvent(new CustomEvent("workspace-changed"));
}, [context.workspaceId]);

setContextState の updater は state を計算するだけ、副作用は useEffect で。warning が消え、code は読みやすくなりました。

観測性:重要な判定経路は本番でも console.log を残す

バグの早期発見のため、重要な判定経路には 本番ビルドでも残す console.log を配置:

[Boot] phase: X → Y | reason
[bootSequence] firebase auth resolved { hasUser, uid, email }
[Account-Type] WorkspaceSwitcher.resolve { workspaceId, isPersonal, isSyncable }
[Account-Type] UserContextProvider.workspaceChanged { ... }
[WorkspaceListProvider] /users/me/workspaces fetched { count }
[switchAccount] start { currentRegistryKind, currentOpenedKind, targetKind }
[PGlite] Opening meta data dir: idb://niyase-XXX (account=XXX)

これで「アカウント種別の判定が間違っている」「想定外の namespace で書き込みが走った」を 後追いではなく現場で 検知できるようになりました。

構造的に消えたバグ

旧バグ防止メカニズム
ローカル切替後も cloud スペース が見えるscoped key を openedAccountId anchor 化
個人スペース flag の漏洩同上
/users/me/workspaces 重複 fetch / 401 ループProvider 階層で Firebase 限定。ローカル時は Provider 自体が mount されない
WorkspaceProvider レースコンディションで user-context:local 汚染scoped key anchor が PGlite namespace 基準
Cannot update SidebarProvider while rendering UserContextProvider 警告updater pure + useEffect で副作用
ログイン直後の画面のちらつきBootOverlay の一貫表示

「if ガードを散らす」のではなく「構造上不可能にする」という設計です。1 つずつ手で防いでいたものが、根本から消えていきます。

教訓

  1. 対症療法はかえって解析を難しくする
    • 「ローカル時に呼ばないガード」を component 5 箇所に散らす形は、component を追加するたびに必ず漏れます
    • Provider 階層で物理的に分離する方が安全です
  2. state Source of Truth は 1 つに集約する
    • 2 つ並立すると「不一致の瞬間」が必ず生まれます
    • 本件は registry と openedAccountId の 2 層ですが、scoped key の anchor を後者に固定することで「実体としては 1 つ」に近づけました
  3. React 19+ の pure updater ルールに従う
    • updater 内の副作用は警告の対象になり、StrictMode で 2 回走ります
    • useEffect で外に出します
  4. boot シーケンスは 1 つの async 関数で記述する
    • useEffect の偶発的な発火順序に依存すると、デバッグが非常に困難になります
    • 逐次的なシーケンスを 1 つの関数に集約します
  5. 観測ログは本番でも残す
    • 「壊れたか分からない」状態が最も困ります
    • [Boot] phase: X → Y を見れば現場で診断できます

まとめ

8 個のバグを 1 つずつ patch するのではなく、構造で消す 設計に切り替えました。新規ファイル 9 つ + 既存 10 数ファイルの修正、合計 3 日。

「リファクタは大きいほど不安だ」と感じる気持ちは分かりますが、対症療法を続けると「次のバグでまた壊れる」という不安が常につきまといます。一度根本から整理する方が、長い目で見ると安全 という体験でした。


設計原則は社内の設計ドキュメントに明文化しました。今後の追加修正で同じパターンが再導入されないようにしています。