TanStack

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars380
mentions21
08162432Oct 5Oct 19Nov 3
UTC days · repositories TanStack owns4 repositories

Package downloads

Cumulative recorded downloads · since 2025-10-18

npm27.1K
06.8K13.6K20.4K27.2KOct 5Oct 19Nov 3
Oct 16npm: 2/2 packages observed

Across 2 known packages in repositories TanStack owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete.

Top repositories

TanStack’s repositories ranked by pull request authors, as on trending.

Loading

Contributes to

Repositories outside TanStack’s account where TanStack opened or reviewed pull requests in the past 365 days.

Who noticed TanStack

Everyone who starred or forked TanStack’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

20posts
Nov 40 posts
  1. Setup, including the Rsbuild config: TanStack/redact Thanks to yslpn for the request: redact#124

  2. Setup, including the Rsbuild config: TanStack/redact Thanks to yslpn for the request: redact#124

    1 like · 901 views
  3. Type-safe AI SDK for any provider A TypeScript SDK that lets you stream chat, tool calls and multimodal prompts with full type safety. Import only the adapters you need—swap OpenAI, Anthropic or Gemini in a single line. 🍒 Built by the TanStack team TanStack/ai #opensource #typescript

    1 like
  4. 今から読むならRedactから読むのがよさそう(読んでないのにも関わらず軽率に適当にオススメしている) TanStack/redact

    7 likes · 843 views
  5. TanStack Redact: Ship Less React Without Touching Your React Code I've been doing frontend for about twelve years, and a big part of that has been fighting bundle size. Code splitting. Lazy routes. Swapping heavy date libraries for lighter ones. Hunting down that one dependency that pulls in all of lodash. A Webpack 4 to 5 migration done with codemods because doing it by hand across a big codebase wasn't realistic. But there was always one chunk of the bundle nobody touched: React and React DOM themselves. You just accepted that cost. It was the floor. TanStack Redact just lowered the floor. And the reason I think it matters isn't the clever engineering behind it (though there's plenty of that). It's how useful it is on day one. What Redact actually is Redact is a smaller, React-compatible runtime for the browser. You add one plugin to your build config, and your existing React imports, components, hooks and third-party libraries run on Redact in the browser instead of React DOM. That's the whole pitch. No new API to learn. No import { h } from 'somewhere-else'. No rewriting components. Your app code stays exactly the same. It started as Tanner Linsley's experiment on his personal site and tanstack.com, where it's been running in production since April 2026. It stuck, other people started using it in production too, and on October 26 it shipped as 0.2.0 with stable integrations for TanStack Start, Vite, Vinext and Next.js (both App Router and Pages Router). The usefulness is the headline Most performance wins come with a migration cost. Move to a new framework, adopt a new rendering model, rewrite your state layer, retrain the team. The win is real, but you pay for it in weeks. Redact flips that. Here's a plain Vite + React app: // vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import { redact } from '@tanstack/redact/vite' export default defineConfig({ plugins: [react(), redact()], }) And TanStack Start: // vite.config.ts import { defineConfig } from 'vite' import { tanstackStart } from '@tanstack/react-start/plugin/vite' import react from '@vitejs/plugin-react' import { redact } from '@tanstack/redact/vite' export default defineConfig({ ssr: { noExternal: true }, plugins: [tanstackStart(), react(), redact()], }) For Next.js there's a withRedact wrapper from @tanstack/redact/next (the docs have the exact setup). That's it. Install, add a line, build, measure. Think about what this means in a real team: - No refactor PR. Your diff is a config file and a package.json line. Easy to review, easy to reason about. - Rollback is trivial. Remove the plugin and you're back on React. That makes it a low-risk experiment instead of a bet-the-quarter decision. - Your ecosystem comes with you. Component libraries, form libraries, query libraries — they keep importing react and just get Redact in the browser. - Nobody has to learn anything. New hires still write React. Your docs, your Storybook, your patterns all stay valid. I've led teams where the right performance move was blocked purely because it meant touching hundreds of components. A tool that gives you the win without that cost is the kind of thing that actually gets adopted. The numbers The TanStack team built the same small app with React and with Redact in each framework, then added up the gzip size of all emitted client JS and CSS: | Framework | React | Redact | Saved | | --- | --- | --- | --- | | TanStack Start | 109.3 KB | 62.3 KB | 47.0 KB (43.0%) | | Vite | 68.3 KB | 20.1 KB | 48.1 KB (70.5%) | | Vinext | 138.1 KB | 98.8 KB | 39.3 KB (28.5%) | | Next.js | 175.6 KB | 139.9 KB | 35.7 KB (20.4%) | Those are the default builds. If you turn off optional features your app doesn't use, Start drops to 59.8 KB and a client-only Vite app goes down to 12.6 KB. The team is upfront that these are whole-build totals for a small sample app, so your percentages will vary. The benchmark source is public if you want to reproduce it. I appreciate that honesty — it's rare in launch posts. Why do 35–48 KB of gzip matter? Because that's JavaScript every user downloads, parses and executes before your app becomes interactive. On a fast laptop on fiber, sure, you won't notice. On a mid-range Android phone on a patchy mobile connection — which is a huge share of real users in markets like India — that's a noticeable chunk of your load budget, and it's coming out of the one dependency you thought you couldn't shrink. It's not a toy React My first worry with any "React but smaller" project is: what breaks? A counter demo working proves nothing. Real apps lean on hydration, Suspense, transitions, forms, error boundaries, and a pile of framework internals. Redact supports JSX, hooks, context, refs, memo, lazy, portals, error boundaries, streaming SSR and hydration. Pending transitions, form actions and optimistic state are covered too. What gave me more confidence is how they test it. The compatibility suite runs real browser journeys in Chromium, Firefox and WebKit: retained layouts, back/forward navigation, redirects, canceled navigation, Server Actions, pending forms, optimistic updates, uploads, streamed Suspense, and Fast Refresh that keeps state across edits. That's the stuff that actually breaks in production. And you don't lose your tooling: React DevTools still works for inspection, editing and commit profiling in development. Server Components stay with React This is a really smart design call. With Next.js and Vinext, Redact doesn't try to replace everything. React still does server rendering and Server Components. The framework still owns routing, caching and Server Action transport. Redact picks up in the browser, hydrates the HTML, and runs your client components. So you're not swapping out your framework's server story. You're only swapping the part that ships to your users' devices. That keeps the blast radius small, and it's exactly where the bytes matter. Go smaller if you want to For Start and Vite you can tune what's included: // Start: keep hydration and router features, drop what you don't use redact({ features: { activity: false, fragmentRefs: false, portal: false, }, }) // Vite: a client-only app with no optional features redact({ preset: 'nano' }) The default is a sensible "full" preset, and the docs recommend starting there and only disabling features after checking what your app (and its dependencies) actually use. Next.js and Vinext use the full runtime so framework features keep working. "Why not just use Preact?" Fair question, and the Redact team answers it directly: Preact is smaller. But Redact keeps more of the modern React behavior your app may depend on, like pending transitions, deferred results and form actions. If you're on a modern React stack and you don't want to discover compatibility gaps one bug report at a time, that matters more than the last few kilobytes. The honest tradeoff Redact renders synchronously. No time slicing, no interruptible renders. Suspended transitions, pending forms and optimistic state still work, but a genuinely heavy render can block input while it runs. For a lot of apps, that's fine. Marketing and content sites, docs, ecommerce storefronts, typical CRUD dashboards, embedded widgets — most of their renders are small, and the bundle savings are pure win. Where I'd be careful: screens that render huge trees in one go, or code that relies on useTransition to keep typing responsive during an expensive update. The TanStack team says the same thing: try it against your busiest screens first. Their compatibility guide spells out the boundaries clearly. That's not a knock on Redact. It's a clear, documented choice, and it's the reason the runtime can be this small. Why I'm a fan What I like most is the attitude behind it. This isn't "React is bad, switch to X." Tanner is explicit that he still believes in React and wants it to keep growing. Redact is just another runtime option inside the React ecosystem for people who want to keep their components and ship less. That's the kind of tool I want more of: respects the code you already have, gives you a real win, tells you the tradeoff upfront, and lets you walk back with one line if it doesn't fit. If you have a React app on Vite, TanStack Start or Next.js, the cheapest experiment you'll run this month is: 1. pnpm add @tanstack/redact 2. Add redact() (or withRedact) to your config 3. Build, compare your bundle, click through your heaviest screens Worst case you learn something about your app. Best case you delete tens of kilobytes from every page load without changing a single component. Links - Launch post: tanstack.com - Docs: tanstack.com - Compatibility guide: tanstack.com - GitHub: TanStack/redact

  6. @AlemTuzlak @AdamRackis @anuraggoel @ramzcoder @render @AlemTuzlak PR is up for review: ai#1946

    3 likes · 35 views
  7. React Compiler 導入で画面が更新されなくなった話 〜既存SPAでの主要ライブラリとの衝突と解決の記録〜 ! 検証環境 React 19.2.8 / babel-plugin-react-compiler 1.0.0 Vite 8.1.5 @vitejs/plugin-react 6.0.4 @rolldown/plugin-babel 0.2.4 @babel/core 8.0.7 React Hook Form 7.82.0 TanStack Router 1.170.18 TanStack Table 8.21.3 Vitest 4.1.10 Oxlint 1.75.0 記事中のコード・挙動・リンクはすべて上記バージョンでのものです。公開時点では TanStack Router・Oxlint などに新しいバージョンが出ており、一部の挙動や設定は変わっています。該当箇所ではその旨を注記しています。 はじめに こんにちは☀️ 株式会社AI Shiftでフロントエンドエンジニアをしているさっちゃんです!(X:おさわ / 吉岡 爽) 今回は、プロダクトに React Compiler を導入した際に UI に発生した不具合と、その対処について書きます。 私達のプロダクトでは React を採用し、Vite と TanStack Router を用いて、SPA を構築しています。フォームには React Hook Form、一覧には TanStack Table を使っています。 React Compilerの導入にあたり、導入前の実装をCompilerのON/OFFで比較しました。その中で、React Compiler を有効にしてテストを実行したところ、いくつかの箇所で異常が検知されました。これをきっかけに実際の画面上で動作確認を行った結果、サイドバーが画面遷移に追従しないなど、ユーザーの操作が意図通りにUIへ反映されない不具合が発覚しました。 この記事では、実際の画面の操作へ影響していたこのサイドバーの問題を中心に、設定・切り分け・原因・対処を紹介します。 この記事について 想定読者:React Compiler の導入を検討している方 書くこと 既存の SPA に React Compiler を導入したときの設定 導入前の実装に Compiler を適用し、ON/OFFで挙動を比較した方法 TanStack Routerの不具合と、React Hook Form の挙動変化、その原因・対処 テストの失敗と、実際の画面への影響を分けて確認したこと 書かないこと React Compiler 導入による性能改善の計測結果 React Compiler の内部実装の詳細 SSR / React Server Components 環境での挙動 導入設定 まずは、今回の Vite 8 + Babel プラグイン環境で React Compiler を導入した際の設定を紹介します。 パッケージの追加 React Compiler を導入するため、以下のパッケージをインストールします。 pnpm add -D -E babel-plugin-react-compiler@1.0.0 @rolldown/plugin-babel@0.2.4 @babel/core@8.0.7 Vite Ver.8 ではバンドラに Rolldown、JS/TS や JSX の変換に Oxc が使われています。 また、 @vitejs/plugin-react v6 では内部の Babel 依存が排除されました。そのため、今回使用した v6.0.4 で、Babelプラグインとして提供される React Compiler を適用するために、@rolldown/plugin-babel を組み合わせました。 ! @vitejs/plugin-react 6.1.0 以降では、oxc-transform-react を用いて Babel なしで Compiler を有効化する実験的な機能も追加されています。 react({ compiler: true }) この記事では、より実績があり安定している Babel プラグイン版を対象とします。 参考:@vitejs/plugin-react 6.1.0 (2026-08-19) CHANGELOG.md また、React Compiler はバージョンアップによってメモ化の挙動が変わる可能性があるため、予期せぬ不具合を防ぐ目的で -E フラグを使い、exact バージョンでインストールしています。 vite.config.ts の実装 Compilerに関係する部分を抜き出すと、次のようになります。 import { defineConfig } from "vite"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; import babel from "@rolldown/plugin-babel"; const compilerPreset = reactCompilerPreset(); compilerPreset.rolldown.filter = { ...compilerPreset.rolldown.filter, id: { exclude: [/node_modules/, /.(test|spec).[jt]sx?$/] }, }; export default defineConfig({ plugins: [ react(), ...(process.env.REACT_COMPILER === "false" ? [] : [babel({ presets: [compilerPreset] })]), ], }); @vitejs/plugin-react が提供する reactCompilerPreset() をベースに、この設定に加えてLintも導入しました。 1. node_modules とテストファイルを変換対象から外す まず、node_modules 配下のライブラリは自分たちが管理しているコードではないため、変換対象から除外しました。 また、.test.* や .spec.* などのテストファイルも外しています。テストコード内で定義したモックやラッパーコンポーネントまで Compiler の対象になってしまうと、テストが失敗したときに、本体の変換による影響なのか、テスト側の変換による影響なのかを切り分けることが難しくなるためです。 2. 環境変数で無効化できるようにする なにか不具合が起きたとき、それが元のコードの問題か、Compiler による変換の影響かを切り分けやすくするために、REACT_COMPILER=false という環境変数を渡すことで、Compiler を無効化した状態で起動・ビルド・テストができるようにしました。 Vitest でも Compiler が適用されているか確認する 先ほどテストファイル(.test.ts など)自体は変換対象から除外しましたが、テストを実行できても、テスト対象のコードにCompilerが適用されていなければ、導入後の挙動を検証したことにはなりません。 reactCompilerPreset() は、Vite の Environment API において「クライアント向けのビルド・変換時」にのみ Babel(Compiler)を適用するように初期設定されています。 Vitest 4 では、jsdom 環境は実際には Node.js 上で実行されるため、モジュールの読み込み自体は SSR(サーバー)側の仕組みを使います。しかし、Vitest 4 が Vite のプラグインパイプライン(Environment API)を呼び出す際、jsdom のようなブラウザの動きを再現する環境では 「コンポーネントの変換(Transform)処理においては Client 環境向けのプラグイン設定を適用する」 という設計になっています (Vitest のソースコード)。 そのため、開発者が vite.config.ts で applyToEnvironment などのフックを上書きして「常に全環境でBabelを動かす」といった設定をしなくても、自動的にテスト用のコンポーネントに Compiler が適用されます。 念のため、コンポーネントの関数を文字列化して、Compiler が挿入するキャッシュ(const $ = ...c(10) のような記述)が含まれているかを直接確認してみました。 import { CommandPalette } from "@/components/ui/CommandPalette"; test("Compiler が適用されている", () => { expect(CommandPalette.toString()).toMatch(/const \$ = .*.c)(\d+)/); }); 設定 結果 初期設定のまま 適用される REACT_COMPILER=false 適用されない 初めは、jsdom 環境には自動適用されないのではと疑い、applyToEnvironmentHook が常に true を返すように設定を上書きしていました。 しかし検証の結果、上書きしなくても正しく適用されることがわかりました。 3. Lint で Rules of React を守る React Compiler が正しく動くには、コードが Rules of React に沿って書かれていることが前提です。 あなたのアプリケーションでコンパイラを導入できるかどうかは、コードベースの健全性と React のルールをどの程度遵守しているかに依存します。 出典:React Compiler:安全に使用できるか? Compiler は、最適化を安全に適用できないと判断したコードについて、対象の最適化をスキップすることがあります。 ルールに違反している可能性のあるコードに遭遇した場合、アプリケーションの動作を変更するリスクを冒すのではなく、最適化を安全にスキップします。 出典:React Compiler:コンパイラの動作を理解する これを防ぐため、Oxlint の react/react-compiler ルールを error レベルで有効にして CI 等で検知できるようにしました。 { "plugins": ["react"], "rules": { "react/react-compiler": "error" } } これで Rules of React への違反をある程度未然に防げます。ただし、Lint が通ったからといって Compiler 由来の不具合が完全にゼロになるわけではありません。この記事で紹介する不具合も、Lint では検出できずテストで発覚したものでした。 ! ここで設定している react/react-compiler は、Oxlint 1.78.0 までのルールです。 1.79.0 で purity・refs・immutability などの個別ルールに分割され、react/react-compiler は削除されました(Oxlint CHANGELOG 1.79.0、Oxc 公式ブログ)。 1.79.0 以降でこの設定を書くと、Rule 'react-compiler' not found in plugin 'react' で設定ファイルの読み込みに失敗します。代わりに次のように設定してください。 { "plugins": ["react"], "categories": { "correctness": "error" } } 検証方法 テストが失敗した際、それが本当にReact Compiler の変換による影響なのかを正確に切り分ける必要がありました。 そこで、Compiler 導入前のコードベースに、設定・依存・比較用のテストを持ち込み、Compiler の ON/OFF 以外の条件を完全にそろえた状態を作って検証を行いました。 以下の手順は、Vitest に unit プロジェクトが定義され、jsdom 環境と、先ほどの Vite プラグイン設定を利用する構成を前提にしています。今回の設定では、extends: true で親の Vite 設定を引き継いでいます。関係する部分を抜き出すと、次のようになります。 // vite.config.ts // pluginsなどは、前述のCompiler設定と同じ export default defineConfig({ plugins: [ // 前述のViteプラグイン設定 ], test: { projects: [ { extends: true, test: { name: "unit", environment: "jsdom", globals: true, include: ["src/**/*.{test,spec}.{ts,tsx}"], }, }, ], }, }); 手順 導入直前のコミットを別ディレクトリに展開する git worktree add ../before <導入直前のコミット> cd ../before Compiler の設定・依存と、比較に使うテストを、導入後のコミットから持ってくる git checkout <導入後のコミット> -- package.json pnpm-lock.yaml vite.config.ts <対象のテストファイル> pnpm install Compiler 無効 REACT_COMPILER=false pnpm vitest run --project unit <対象のテストファイル> Compiler 有効 pnpm vitest run --project unit <対象のテストファイル> Compiler の切り替えには、前の章で用意した環境変数 REACT_COMPILER を使いました。 結果 フォーム・ナビゲーション・共通 UI に関わる77件のテストで比べた結果です(導入時点の計測)。 実装 Compiler 結果 導入前 無効 77件成功 導入前 有効 71件成功・6件失敗 対応後 有効 77件成功 失敗した6件は、次の2種類に分かれました。 種類 件数 症状 TanStack Router を使ったサイドバー 2件 SPA内の遷移後、子ページを含むグループが開かない React Hook Form を使ったダイアログ 4件 キャンセル直後、終了アニメーション中の入力欄に編集値が残る これらの失敗がテスト環境特有のエラーなのか、実際のUIに影響するものなのかを確かめるため、ブラウザ上で実際に操作して挙動を確認しました。 結果として、どちらも Compiler によって実際に画面の挙動が変わってしまった状態でした。ただし、ユーザーへの影響は異なります。サイドバーは現在地のリンクが見えなくなる一方、ダイアログは終了アニメーション中だけ値が残り、再表示時には初期化されていました。 対応の概要 見つかった2つの問題には、それぞれ次のように対処しました。 問題 今回の対処 次のステップ サイドバーのグループが開かない(TanStack Router) useMatchRoute を、毎回新しい関数を返すラッパーフック経由で使う 修正済みの TanStack Router v1.170.27 以降へ上げ、ラッパーを外す キャンセル直後に入力値が残る(React Hook Form) 対象のダイアログに "use no memo" を付け、Compiler の変換対象から外す reset() に keepFieldsRef: true を指定し、"use no memo" を外す どちらも、導入時点では既存の挙動を確実に保つことを優先した一時的な対応です。 特にダイアログの "use no memo" は、Compiler の最適化をあきらめることで不具合を避けている状態です。本来は、Compiler の最適化を受けたまま直せる keepFieldsRef に移行を検討しています。 なお、サイドバー側の "use no memo" は数行のラッパーフックにだけ付けており、サイドバーのコンポーネント自体は引き続き Compiler で最適化されています。 壊れたもの:サイドバーの外から移るとグループが開かない 症状 サイドバーでは、関連する子ページのリンクを、開閉できる見出しの下にまとめています(以下、グループと呼びます)。 現在のページがグループ内の子ページであれば、そのグループを自動で開き、現在地がわかるようにしています。 しかし、サイドバー以外の導線から、ページ全体を再読み込みせずに子ページへ遷移すると、ページ本体は切り替わるのに、サイドバーのグループが開きませんでした。 例えば、アプリ概要から設定グループ内の子ページへ遷移すると、ページ本体は切り替わるのに、サイドバーの設定グループは閉じたままになっていました。 👇 再現用の簡易アプリで、ダッシュボードのカードから「通知」へ移動したときの動き Before(修正前):ページは切り替わるが、サイドバーの「設定」は閉じたまま After(修正後):「設定」が開き、パンくずも「通知」に更新される サイドバーは、以下のように useMatchRoute() でリンクがアクティブかどうかを判定し、その判定関数をグループのコンポーネントに渡していました。 function NavEntryItem({ entry, params }: Props) { const matchRoute = useMatchRoute(); const isActive = (link: NavLink) => matchRoute({ to: link.to, params }) !== false; return <NavGroupItem entry={entry} isActive={isActive} />; } function NavGroupItem({ entry, isActive }: GroupProps) { const hasActiveChild = entry.children.some(isActive); // hasActiveChild が true になったらグループを開く // ... } 原因 useMatchRoute が返す関数は、URL が変わっても同じ参照のままだった 今回使用していた TanStack Router v1.170.18 では、useMatchRoute() の実装は次のようになっていました。型やオプションの処理を省略しています。 ! この節に掲載する Matches.tsx と router.ts のコードは、TanStack Router の実装から、説明に必要な部分を抜粋・簡略化したものです。 ライセンス:MIT License 著作権表記:Copyright (c) 2021-present Tanner Linsley 引用元コミットの LICENSE 全文 実際のソースコードMatches.tsx の実装 export function useMatchRoute() { const router = useRouter() useStore(router.stores.matchRouteDeps, (d) => d) // 👇 router インスタンスだけを依存配列にしている return React.useCallback((opts) => router.matchRoute(/* options を渡す */), [router]) } ブラウザ側ではルーターの状態を購読しているので、URLが変わると、このフックを使うコンポーネントは再レンダーされます。 一方、返す関数の依存配列は [router] なので、同じ router インスタンスを使っている間、URLが変わっても関数の参照は変わりません。 しかし matchRoute は、引数だけで結果が決まるわけではなく、「現在の URL」を読み取って判定を行っています。 ソースコードを確認してみると、router.matchRoute() は、呼び出された時点の現在地をルーターの内部状態から読み取っていました。 実際のソースコード router.ts の実装 // 👇 呼ばれた瞬間に、ルーターから現在地を取得している const baseLocation = this.stores.resolvedLocation.get() || this.stores.location.get() const match = findSingleMatch(next.pathname, /* ... / baseLocation.pathname, / ... */) 同じ matchRoute({ to: "/settings" }) という呼び出しでも、現在の URL によって戻り値が変わります。一致した場合はルートパラメータのオブジェクト、不一致なら false です。そのため、次の式は、アプリ概要ページでは false、設定ページでは true になります。 matchRoute({ to: "/settings" }) !== false; この「参照は同じでも、内部状態によって結果が変わる」という性質が、今回のメモ化と衝突しました。 Compilerによって何がキャッシュされたのかを確認する 実際のコンポーネントを babel-plugin-react-compiler@1.0.0 で変換すると、isActive の生成に次のようなキャッシュが入っていました。 // 【1】matchRoute や params の参照が変わったかチェック if ($[0] !== matchRoute || $[1] !== params) { t1 = (link) => matchRoute({ to: link.to, params }) !== false; // 新しく関数を作る $[0] = matchRoute; $[1] = params; $[2] = t1; // 作った関数をキャッシュに保存 } else { // どちらも変わっていなければ、前回の関数(キャッシュ)を使い回す! t1 = $[2]; } const isActive = t1; // 【2】NavGroupItem に渡す props(entry や isActive)が変わったかチェック if ($[3] !== entry || $[4] !== isActive) { t2 = <NavGroupItem entry={entry} isActive={isActive} />; // 新しいJSX要素を作る // ... } ここで登場する変数は、それぞれ次のものを表しています。 matchRoute:useMatchRoute() が返す、指定した遷移先が現在の URL に一致するか判定する関数です。一致するとルートパラメータのオブジェクト、不一致なら false を返します。 params:遷移先の URL を組み立てるためのルートパラメータです。IDやslug、環境などが入ります。同じID・環境内で概要ページから設定ページへ移る場合、これらの値は変わりません。 entry:サイドバーに表示する項目の定義です。グループの場合は、「設定」のような見出しや、配下に表示する子リンクの情報を持っています。現在のページが変わっても、グループの定義自体は変わりません。 $:Compiler がコンポーネントのインスタンスごとに用意するキャッシュ用の配列です。$[0] には前回の matchRoute、$[1] には前回の params、$[2] には生成した判定関数を保存しています。 t1・t2:Compiler が生成した一時変数です。t1 には isActive として使う判定関数、t2 には <NavGroupItem ... /> という JSX 要素が入ります。 今回の実装では、entry はコンポーネントの外に定義した固定の項目一覧から渡しています。また、Compiler によって、親が作る項目一覧の JSX もキャッシュされていました。今回のページ遷移ではそのキャッシュが再利用されたため、JSX に含まれる params オブジェクトも作り直されませんでした。このため、子が URL の変更を受けて再レンダーされても、entry と params は前回と同じ参照のままでした。 この結果を読み解くと、以下のようになります。 URL が変わり、useMatchRoute() の購読によって親が再レンダーされる matchRoute と params の参照が変わらないため、Compiler によって isActive が再利用される entry と isActive の参照も変わらないため、子へ渡す JSX が再利用される この経路では NavGroupItem の再レンダーが省略され、hasActiveChild が再計算されない 子ページへ移っても、グループが閉じたままになる Compiler なしなら、毎回のレンダーで matchRoute(...) が呼び直されるため、この問題は表に出ていませんでした。 ! この問題は TanStack Router 側でも報告されており(#4499)、v1.170.27 で修正されました(#8015)。 「対応の概要」のとおり、今後は、バージョンを上げて対応することも検討しています。 対処 毎回新しい関数を返すラッパーを作る useMatchRoute を直接使わず、次のラッパーフックを経由するようにしました。 import { useMatchRoute } from "@tanstack/react-router"; export function useReactiveMatchRoute() { "use no memo"; const matchRoute = useMatchRoute(); const reactiveMatchRoute: typeof matchRoute = (options) => matchRoute(options); return reactiveMatchRoute; } やっていることは、matchRoute を実行するだけの関数をレンダーのたびに新しく作って返すことだけです。 "use no memo" ディレクティブをつけることで、このラッパーフック自体が Compiler に最適化されるのを防いでいます。 "use no memo" は、関数単位で Compiler の変換を止めるディレクティブです。 これにより、URL が変わって useMatchRoute の購読によって再レンダーが走るたびに、呼び出し元には必ず前回と違う参照を持った新しい関数が返るようになります。 呼び出しは以下のように変更しました。 function NavEntryItem({ entry, params }: Props) { const matchRoute = useReactiveMatchRoute(); const isActive = (link: NavLink) => matchRoute({ to: link.to, params }) !== false; return <NavGroupItem entry={entry} isActive={isActive} />; } URLが変わると、元の useMatchRoute() の購読によって再レンダーが発生します。その際、ラッパーからは新しい関数が返ります。 その結果、呼び出し側のコンポーネントでは Compiler のキャッシュ判定 ($[0] !== matchRoute) が正しく外れるようになり、isActive の再計算も、<NavGroupItem> の再描画も正常に行われるようになりました。 挙動が変わったもの:終了アニメーション中の入力リセット 症状 各種フォームのダイアログにおいて、値を入力してからキャンセルでダイアログを閉じるアニメーションの中で、入力値が残ってしまっていてテストが落ちていました。 再度ダイアログを開くと残っていなかったため、ユーザーへの影響は少ないと判断しましたが、こちらも原因を調査して対処しました。 function AccountCreateDialog({ isOpen, onClose }: Props) { const { register, reset } = useForm({ defaultValues: { name: "" } }); const close = () => { onClose(); reset({ name: "" }); // ← ここでリセットしている }; return ( <Dialog isOpen={isOpen}> <input {...register("name")} /> <button onClick={close}>キャンセル</button> </Dialog> ); } 原因 原因は、React Compiler のキャッシュ、React Hook Form の reset() の仕組み、React の ref コールバックの仕組みの3つが組み合わさったことでした 実際のダイアログコンポーネントを React Compiler で変換すると、register("name") の呼び出しは次のようにキャッシュされていました(出力コードの抜粋)。 if ($[25] !== register) { // register の参照が前回と違う場合(初回レンダーなど) t12 = register("name"); // 関数を実際に呼び出して結果を取得 $[25] = register; // 今回の register の参照をキャッシュに保存 $[26] = t12; // 呼び出した結果をキャッシュに保存 } else { // register の参照が変わっていない場合 t12 = $[26]; // 関数を呼び出さず、前回の結果を使い回す } $: 今回はインデックス 25 の枠に register 関数の参照を、26 の枠にその実行結果が保存されていました。 t12: register("name") の実行結果(onChange や ref などが含まれたオブジェクト)が代入され、最終的に JSX 側で <input {...t12} /> のように展開されます。 Compiler は register 関数の参照が前回と同じなら、関数を呼び出さずに前回の結果を使い回す という最適化を行ってくれます。 この最適化が、React Hook Form の2つの仕様とぶつかりました。 React Hook Form が持つ2つの仕様 register 関数は常に同じ参照だが、呼んだ結果は毎回新しい useForm が返す register 関数は、最初のレンダーで一度だけ作られ、その後は再レンダーされても同じ参照のままです。 一方、register("name") を呼んだ結果(戻り値)は、呼ぶたびに新しく作られます。戻り値に含まれる ref コールバック(ref に渡す関数)が、毎回新しい関数になるためです。 今回の reset() は登録済みフィールドの参照を破棄する 今回は保持オプションを指定していないため、フォームの値をリセットするとともに、React Hook Form が内部で保持するフィールドの参照も破棄されます。 Reset the entire form state, fields reference, and subscriptions. (出典:React Hook Form 公式ドキュメント - reset) React Hook Form は、「直後の再レンダーで再び register("name") が呼ばれ、入力欄が登録し直されること」 を前提に動いています。 keepFieldsRef: true を指定すると、今回のリセットでフィールド参照を保持できます。後ほど、この方法も紹介します。 なぜ画面に古い値が残るのか Compiler が register("name") の呼び出しをスキップする register("name") の引数 "name" は固定値であり、register 関数の参照も再レンダー間で変わりません。 Compiler は「同じ関数を同じ引数で呼ぶなら結果も同じはず」と判断し、2回目以降のレンダーで register("name") の実行をスキップして前回の戻り値(ref コールバック等を含むオブジェクト)をそのまま使い回します。 React が ref コールバックを呼び直さない 同じ入力 DOM が維持されている再レンダーでは、ref コールバックの参照も同じなら、React は ref コールバックを呼び直しません。今回、終了アニメーション中はこの条件に当てはまり、入力欄の再登録と値の書き込みが行われませんでした。 React Hook Form が画面を更新できない React Hook Form は、この ref コールバックが実行されるタイミングで入力欄を内部に再登録し、reset() 後の値を DOM へ書き込んでいます。ref が呼ばれないため、DOM への書き込みも再登録も行われません。 その結果、終了アニメーション中の入力欄には編集した値が残りました。一方、閉じ切った後は入力 DOM がアンマウントされます。再表示時には同じ ref コールバックでも呼び出されるため、初期値が反映されました。 対処 今回のPRでは、従来の「キャンセル直後に入力をリセットする」挙動を維持するため、対象ダイアログを Compiler の変換対象から外しました。 register() と reset() を組み合わせて使うダイアログは、コンポーネントの関数本体の先頭に "use no memo" を書くように対処しました。 function AccountCreateDialog({ isOpen, onClose }: Props) { "use no memo"; const { register, reset } = useForm({ defaultValues: { name: "" } }); // ... } "use no memo" を指定したコンポーネントは、その内部で生成される JSX や関数(子コンポーネントへ渡す props など)も Compiler のキャッシュ対象外となります。 局所的に最適化の恩恵は受けられなくなりますが、これ以外の独立したコンポーネントは引き続き Compiler で最適化されます。 あわせて、「閉じてもう一度開いたあとも、入力と検証が正しく動く」ことを確かめるテストを追加し、キャンセル直後に入力欄が空になっているかだけでなく、フィールドが登録し直されているかまで確認するようにしました。 今回はダイアログがアンマウントされるため再表示時には正常に戻っていましたが、仮にページに残り続けるフォームだった場合、reset() 以降は React Hook Form と DOM の紐付けが完全に切れてしまい、送信(submit)や値の取得ができなくなるという、より深刻な不具合に発展していた可能性があります。 本来望ましい対処:keepFieldsRef 「対応の概要」で触れたとおり、今回の "use no memo" は一時的な対応です。 今回の挙動変化は、次の流れで起きていました。 reset() がフィールドの参照を破棄する そのため、次のレンダーで register("name") を呼んで登録し直す必要がある ところが Compiler が register("name") の呼び出しをスキップするため、登録し直されない "use no memo" は、3. の Compiler の変換を止めて、登録し直しが起きるようにする 対処です。 一方、1. の破棄そのものをやめて、登録し直しを不要にする 方法もあります。それが reset() の keepFieldsRef オプションです(v7.60.0 以降)。 reset(defaultValues, { keepFieldsRef: true }); このオプションを付けると、reset() は入力欄の登録を残したまま、登録済みの入力欄に直接値を書き込みます。 今回失敗した4つのダイアログでは、対応する reset() に keepFieldsRef: true を指定すると、"use no memo" を外してもキャンセル直後の入力値がリセットされました。 対処 よい点 気になる点 "use no memo" コンポーネントの先頭に1行足すだけ。React Hook Form の挙動を変えない そのコンポーネントは Compiler の最適化を受けられない keepFieldsRef: true Compiler の最適化を受けたまま直せる 対象フォームでフィールド参照を保持する設定が必要。条件によって表示・非表示が切り替わる入力欄がある場合は、影響の確認が必要 ! React 公式ドキュメントでは、"use no memo" は一時的な回避策であり、恒久的な解決策ではないとされています 恒久的な解決策ではなく、一時的なデバッグツールとして使用することを意図しています。 (公式ドキュメント)。 keepFieldsRef への移行や、React Hook Form 側の対応状況を見ながら、将来的には外していくのが望ましいと考えています。 壊れなかったけど先に手を打ったもの テストが落ちたり実際に画面が壊れたりしたわけではありませんが、React Compiler 導入にあたって、事前に手を打った(コードを変更した)箇所もあります。 特に、React Compiler の互換性のないライブラリとして、互換性が現在確認されていないライブラリについては、先行事例(React Compilerについて調べてみた、React Compiler導入で得られた効果と気をつけたいポイント)も参考にしながら、バグを踏む前に対策を入れています。 TanStack Table には "use no memo" を指定 TanStack Table(@tanstack/react-table)は、Compiler との互換性に問題があるライブラリとして名前が挙がっています。 そのため、DataTable コンポーネントには "use no memo" ディレクティブを指定し、安全のために Compiler の変換対象から外しました。これにより、このコンポーネント内で定義するカラム定義や配下の子コンポーネントへの props も Compiler の最適化対象外となりますが、 予期せぬバグを防ぐため現時点では互換性を優先しています。 なお、検証時点の TanStack Table は v8 です。公開時点では v9 がリリースされていますが、v9 と Compiler の組み合わせは今回検証していません。 RHF の watch() を useWatch() に変更 React Hook Form の watch() メソッドも、Compiler との組み合わせで問題が起きやすいことが公式から指摘されています。 そのため、フォームの値を購読してUIを切り替えるような箇所は、フックである useWatch() を使用する形へすべて書き換えました。 手動の useMemo の削除 CommandPalette などのコンポーネントで過去に手動で書いていた useMemo は削除しました。 これはバグ対策というより、「今後のメモ化は Compiler に任せる」という方針に基づいたコードの整理です。 まとめ React Compiler を導入し、一部の手動メモ化を Compiler に任せる形へ整理しました。しかし今回の検証で見えてきたように、外部ライブラリとの組み合わせでは、過剰なキャッシュが原因でバグを引き起こすことがあります。 今回は、Compiler の ON/OFF で挙動を比較し、変換結果とライブラリの実装を確認することで、必要な互換性対応を判断できました。 また、テストが失敗した場合も、どのタイミングで挙動が変わり、ユーザーの操作にどう影響するのかまで確かめることが大切だと感じました。 この記事での調査の過程や回避策のアイデアが、これから React Compiler をプロダクトに導入しようとしている方の参考になれば幸いです! 最後に AI Shiftではエンジニアの採用に力を入れています! 少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか? (オンライン・19時以降の面談も可能です!) 【面談フォームはこちら】

    1 like
  8. Performance at Scale: Optimizing Large and High-Volume Forms - Designing a Form Architecture That Survives Scale - Cut re-renders: minimize DOM churn and validation cost - Virtualize and cache fields without losing user input - Measure what matters: profiling, benchmarking, and CI-friendly tests - Practical Application — checklists, hooks, and snippets Large, high-volume forms die from three predictable things: gratuitous re-renders, synchronous/over-eager validation, and DOM churn from mounting/unmounting fields. Tackle those three and you turn a sluggish 100+ field form into a responsive, resilient data-collection surface. Large forms show symptoms that feel familiar: typing lag on-device, long commit times in the React Profiler, fields that lose value when scrolled out of a virtual list, autosave hammering the backend with many tiny requests, and brittle tests that flake when fields mount/unmount. Those are the places you focus first because they cost user time, conversions, and developer time to debug. Designing a Form Architecture That Survives Scale Treat the form as a data contract first: a single, schema-driven source of truth and small, well-scoped components that only subscribe to what they need. - Use a schema-first approach (for example with Zod) so your validation, types, and API contract live in one place rather than scattered through UI code. That makes step-by-step validation and type-safe transforms predictable. - Wire the schema into your form layer with a resolver (e.g., zodResolver + React Hook Form) so validation runs where you expect it and can be run on-demand instead of per-keystroke. This keeps runtime validation predictable and composable. - For multi-step forms choose one of two patterns: - One form instance across all steps, and validate only the active step with targeted triggers; this keeps all data in one place and simplifies final submission. - Separate form instances per step and stitch the results together server-side—simpler component isolation but more plumbing for cross-step constraints. Table: high-level trade-offs | Approach | Pros | Cons | |---|---:|---| | Uncontrolled inputs + RHF (register) | Minimal re-renders, native input performance | Integrations with controlled UI libs need Controller adapters. | | Controlled (useState / Formik) | Easier to reason in component-local state, simpler third-party controlled components | Re-renders per keystroke — scales poorly with many fields. | | Hybrid (RHF + Controller for specific widgets) | Best balance: RHF performance + compatibility with controlled UI components | More cognitive overhead; avoid Controller for trivial native inputs. > Important: For large forms prefer uncontrolled-first patterns and only adopt Controller when you must integrate a controlled widget (Material UI, custom select, complex datepickers). Controller isolates re-rendering but has a cost compared to native register. Example starter (RHF + Zod): import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const schema = z.object({ firstName: z.string().min(1), age: z.number().int().optional(), }); const methods = useForm({ resolver: zodResolver(schema), mode: "onBlur", // validate less aggressively shouldUnregister: false, // useful for multi-step UIs }); Citations: RHF explains its uncontrolled focus and lower re-render surface as a design point ; schema-first docs for zod and parse options are comprehensive ; resolvers project documents the zodResolver pattern . Cut re-renders: minimize DOM churn and validation cost The single biggest win for responsiveness is preventing unnecessary re-renders — especially the root form component. - Subscribe narrowly. Use useWatch or useFormState to subscribe to only the fields or flags you need. Avoid destructuring the entire formState at the form root (it forces wide re-renders). useWatch will isolate updates to the hook level. - Prefer register (uncontrolled) for native inputs. It keeps input state in the DOM and out of React renders; reading values on demand with getValues() is cheap. Use Controller only for components that expose no ref. - Validate intentionally: - Use mode: "onBlur" or mode: "onSubmit" for large forms — avoid onChange validation on every keystroke. onChange validation creates a lot of computation and re-renders. - For heavy or async checks (e.g., calling an availability API), run them on blur or on explicit trigger(fields) rather than during every change. Use safeParse / parseAsync for async schema refinements when required. - Use setValue with options to avoid side-effectful re-renders. setValue(name, value, { shouldValidate: false, shouldDirty: true }) gives you control over whether state flags trigger updates. Practical patterns that reduce re-renders: - Move expensive display computations outside the input-render path (memoize summaries, charts). - Wrap large static blocks with React.memo. - Avoid inline props or inline event handlers that change identity on each render; pass stable callbacks with useCallback. Short code snippet: isolate dirty indicator with useFormState so the form root doesn’t re-render: // Child component only re-renders when isDirty changes function DirtyBadge({ control }: { control: Control }) { const { isDirty } = useFormState({ control, name: "isDirty" }); return <span>{isDirty ? "Unsaved" : "Saved"}</span>; } Citations: RHF documents useWatch, useFormState and the cost of onChange validation modes; setValue options allow you to avoid unnecessary re-renders. Virtualize and cache fields without losing user input When the number of rows/fields is large (think 100s–1000s), windowing the DOM is necessary — but doing it naively loses uncontrolled input state when rows unmount. Use targeted patterns to keep state consistent. - React’s guidance: virtualize long lists to reduce DOM nodes and render cost. Virtualization dramatically reduces the number of DOM nodes React must reconcile. - Libraries: use react-window or a headless solution like TanStack Virtual for full control. react-window is battle-tested and lightweight; TanStack Virtual is more feature-rich and headless. - With forms, follow RHF’s "working with virtualized lists" advice: - Keep the form values in RHF instead of relying on DOM-only state; use shouldUnregister: false so that fields removed from the DOM don’t lose their registered value. - Render editors in a pooled/sticky editor when inline editing is required (mount the active editor outside the virtualized list and bind it to the selected row), or persist values to RHF on blur before unmount. - Tune overscanCount to avoid excessive mount/unmount churn as the user scrolls; overscan mitigates visual flicker at the cost of a few extra mounted rows. Example pattern (simplified): import { FixedSizeList as List } from "react-window"; import { FormProvider, useForm } from "react-hook-form"; function Row({ index, style, data }) { // mount/unmount — register/unregister handled by RHF return ( <div style={style}> <input {...data.register(`rows.${index}.value`)} /> </div> ); } function WindowedForm({ items }) { const methods = useForm({ defaultValues: { rows: items }, shouldUnregister: false }); return ( <FormProvider {...methods}> <List itemCount={items.length} itemSize={40} overscanCount={5}> {({ index, style }) => <Row index={index} style={style} data={methods} />} </List> </FormProvider> ); } Citations: React recommends windowing for long lists ; RHF’s advanced usage shows concrete examples for keeping values with virtualized lists and warns about unmount-reset issues ; react-window docs explain overscan and API shape. Measure what matters: profiling, benchmarking, and CI-friendly tests You can’t optimize what you don’t measure. Build a small, reproducible benchmark and add it to CI so performance regressions are visible. - Developer-time tools: - Use React DevTools Profiler and the <Profiler> API to locate slow commits and the components responsible for the work. Actual render commit durations are what you optimize, not render counts alone. - Use why-did-you-render during development to find avoidable re-renders; it’s noisy but great for catching ownership/props identity issues before deployment. - Lab tests: - Run Lighthouse user flows or scripted Lighthouse runs to capture performance during an interactive path (e.g., go → open form → fill first 50 fields). Lighthouse user flows let you measure during interactions, not just page load. - Use Playwright (or Puppeteer) to script form work and capture traces. Playwright’s trace viewer records actions, DOM snapshots, and timing so you can correlate a slow keystroke or commit to an exact action. - CI-friendly regression tests: - Add a small synthetic test that populates N fields and asserts the median keystroke-to-render time stays below a threshold. - Capture traces on first failing runs to root-cause regressions quickly. Example Playwright snippet (tracing + simple fill time): // playwright-test.js import { chromium } from "playwright"; (async () => { const browser = await chromium.launch(); const context = await browser.newContext(); await context.tracing.start({ screenshots: true, snapshots: true }); const page = await context.newPage(); await page.goto("http://localhost:3000/huge-form"); const t0 = performance.now(); // simulate filling 200 inputs for (let i = 0; i < 200; i++) { await page.fill(`[data-test="input-${i}"]`, "x".repeat(10)); } const t1 = performance.now(); console.log("fill time ms:", t1 - t0); await context.tracing.stop({ path: "trace.zip" }); await browser.close(); })(); Citations: The Profiler API docs explain what to measure and how to interpret commits ; Lighthouse user flows document scripting interactions and measuring them in CI ; Playwright tracing docs explain the trace format and viewer. Practical Application — checklists, hooks, and snippets This section is a drop-in toolkit: checklists you can run through quickly, and a ready useAutosave hook that follows safe patterns. Run this quick checklist on any large form: - [ ] Use a schema (Zod) that represents the entire data shape. - [ ] Configure RHF with resolver and mode: "onBlur" (or "onSubmit") for the big form. - [ ] Prefer register for native inputs; use Controller only for controlled UI widgets. - [ ] Isolate expensive UI or derived data with React.memo and useMemo. - [ ] For long lists: virtualize with react-window or TanStack Virtual and set shouldUnregister: false. Tune overscanCount. - [ ] Add synthetic performance tests (Playwright / Lighthouse user flows) to CI. - [ ] Implement autosave that debounces, saves only diffs, and falls back to local persistence / background sync when offline. A robust useAutosave (TypeScript + RHF-friendly) - Goals: debounce saves, save only deltas, persist to an offline store when offline, flush on unload, cancel in-flight saves on new changes. // useAutosave.ts import { useEffect, useRef, useCallback } from "react"; import debounce from "lodash.debounce"; type SaveFn<T> = (patch: Partial<T>) => Promise<void>; export function useAutosave<T extends Record<string, any>>( getValues: () => T, watchSubscribe: (cb: (data: T) => void) => { unsubscribe: () => void }, saveFn: SaveFn<T>, opts = { wait: 1200, maxWait: 5000 } ) { const lastSavedRef = useRef<T | null>(null); const inflightRef = useRef<Promise<void> | null>(null); // shallow-diff; return object with changed keys const diff = (a: T | null, b: T) => { if (!a) return b; const patch: Partial<T> = {}; for (const k of Object.keys(b)) { if (a[k] !== b[k]) patch[k as keyof T] = b[k]; } return patch; }; const doSave = useCallback(async () => { const values = getValues(); const patch = diff(lastSavedRef.current, values); if (!patch || Object.keys(patch).length === 0) return; try { inflightRef.current = saveFn(patch); await inflightRef.current; lastSavedRef.current = values; } catch (err) { // simple backoff would go here; for offline, persist `patch` to IndexedDB/localStorage console.error("Autosave failed", err); } finally { inflightRef.current = null; } }, [getValues, saveFn]); // debounced save to avoid network storms const debouncedSaveRef = useRef(debounce(doSave, opts.wait, { maxWait: opts.maxWait })).current; useEffect(() => { // initialize lastSaved lastSavedRef.current = getValues(); const sub = watchSubscribe(() => { debouncedSaveRef(); }); const handleUnload = () => { // flush synchronously on unload if possible debouncedSaveRef.cancel(); // best-effort: call sync save (not guaranteed) void doSave(); }; window.addEventListener("beforeunload", handleUnload); return () => { sub.unsubscribe(); debouncedSaveRef.cancel(); window.removeEventListener("beforeunload", handleUnload); }; }, [getValues, watchSubscribe, debouncedSaveRef, doSave]); } Integration notes: - Use RHF’s watch(callback) subscription (or watch inside a lightweight component) to avoid root re-renders and to feed useAutosave without causing renders. - Persist failed patches to IndexedDB and register a Background Sync so service worker flushes them when network returns. MDN documents the Background Sync API and the SyncManager pattern for this use-case. - Use lodash.debounce (or an equivalent) to throttle saves and give users a smooth typing experience. Small snippet: register background sync (service worker): // in client when offline save to outbox then: const reg = await navigator.serviceWorker.ready; await reg.sync.register("outbox-sync"); Citations: use debounce to prevent request storms ; use localStorage / IndexedDB for persistence when network is flaky (Web Storage / IndexedDB docs) ; Background Sync lets the service worker flush queued requests when connectivity returns . Sources: React Hook Form — FAQs - Explanation of RHF's uncontrolled-first design and why it reduces re-renders. Optimizing Performance — React (legacy docs) - React guidance on windowing long lists and avoiding unnecessary reconciliation. Profiler API – React - How to use the Profiler to measure commit durations and identify hotspots. React Hook Form — Advanced Usage (Working with virtualized lists) - Concrete example and cautions for using react-window with RHF and how to preserve values. bvaughn/react-window · GitHub - react-window docs and API (overscan, List/Grid patterns). TanStack/virtual · GitHub - Headless virtualizer (TanStack Virtual) and usage patterns for complex virtualization. Zod (colinhacks/zod) · GitHub - Zod schema API (parse, safeParse, parseAsync) and rationale for schema-first validation. react-hook-form/resolvers · GitHub - Resolver integrations including zodResolver and how to wire schemas into RHF. Use tools to measure performance — web.dev - Lighthouse, WebPageTest, and RUM guidance for creating measurable performance baselines. Playwright — Trace Viewer docs - How to record traces, inspect actions, and use tracing in CI to debug performance. why-did-you-render · GitHub - Dev-time tool to detect avoidable re-renders and ownership reasons. Web Storage API — Using the Web Storage API (MDN) - Browser storage fundamentals and constraints for localStorage. Background Synchronization API (MDN) - Using SyncManager and service worker sync registration for offline-first sync. lodash.debounce — npm - debounce implementation and options for throttling autosave and heavy callbacks. useForm — React Hook Form docs - useForm options (mode, shouldUnregister, resolver) and guidance on subscription APIs, getValues, setValue, useWatch and useFormState. Every change you make to render scope, validation timing, or virtualization should be backed by a quick profile: add a Profiler span, measure an action end-to-end with Playwright/Lighthouse, and only then harden it into CI. Performance at scale is a discipline: architect with schema-first validation, subscribe narrowly, and instrument the form so regressions are visible and actionable.

  9. @agneymenon @tan_stack looks like router#8801 will solve this

    2 likes · 147 views
  10. TanStack Query v5のonError削除への移行方法 はじめに TanStack Query v5では、useQueryからonSuccess / onError / onSettledが削除されました。v4で「取得に失敗したらエラーダイアログを出す」といった処理をuseQuery({ onError })に書いていたプロジェクトでは、v5に上げた時点でその箇所がすべて型エラーになります。 呼び出し箇所が多いと全部を一度に書き換えるのは大変なので、「useQueryを包んでonErrorを受け取れるようにし、中でuseEffectを使って呼ぶ」という互換ラッパーがよく紹介されています。 この記事の結論は次の2つです。 v4のうちにコールバックを移してからv5に上げれば、互換ラッパーはいりません。v4の時点で、QueryCacheのグローバルなコールバックもmetaも使えます。 先にv5へ上げる事情があってラッパーを使う場合、useEffectでdataを監視するよくある書き方はv4と挙動がずれます。v4とv5を並べて動かし、どこでずれるかを確かめました。v4と同じ挙動にするラッパーの実装と、使うときの注意もまとめています。 検証環境 @tanstack/react-query v5.103.2 / v4.44.0(npmエイリアスで同時にインストール) React 18.3.1 Vitest + Testing Library(jsdom) v5で何が変わったか 公式の移行ガイドに「Callbacks on useQuery (and QueryObserver) have been removed」という節があり、クエリのonSuccess / onError / onSettledが削除されています。useMutationのコールバックはそのまま残っていて、QueryCacheに渡すグローバルなコールバックも使えます。 tanstack.com 削除の理由はRFCにまとまっています。コールバックはuseQueryを呼んだコンポーネントの数だけ実行されるため、共通のフックにonErrorでトーストを書くと、1回の失敗でトーストが何枚も出ます。キャッシュから返ったときには呼ばれないのも、直感と合わず誤用されがちでした。 query#5279 v4のうちにコールバックを移す v5で削除されたコールバックの代わりになる仕組みは、v4にもそろっています。 useQueryのonSuccess / onError / onSettledには、型定義で@deprecated This callback will be removed in the next major version.が付いている(4.44.0で確認) QueryCacheのonError / onSuccess / onSettledが使える クエリのmetaが使える つまり、v4のままコールバックを1つずつ移し、残りが0になってからv5に上げれば、コールバックの削除という破壊的変更には当たりません。どの段階でもアプリはv4で普通に動くので、途中で止めても困りません。 1. 共通のエラーダイアログをQueryCacheとmetaに移す onErrorの中身が「エラーダイアログを出す」だけで、呼び出し箇所ごとに違うのがメッセージだけなら、QueryCacheのグローバルなonErrorにまとめられます。こちらはクエリ1件の失敗につき1回だけ呼ばれます。同じクエリを2つのコンポーネントで使っても1回しか呼ばれないことは、v4.44.0で確かめました。 queryClient.ts(v4) import { QueryCache, QueryClient } from '@tanstack/react-query' declare module '@tanstack/react-query' { interface QueryMeta { errorMessage?: string } } export const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: (error, query) => { openErrorDialog(query.meta?.errorMessage ?? '取得に失敗しました') }, }), }) 呼び出し側はonErrorをmetaに書き換えます。 const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id), - onError: () => openErrorDialog('ユーザー情報の取得に失敗しました'), - meta: { errorMessage: 'ユーザー情報の取得に失敗しました' }, }) 拡張しない場合、v4のQueryMetaは任意のキーを受け付け、値の型はunknownになります。上のようにQueryMetaを拡張しておくと、errorMessageを文字列として扱えます。 注意点として、QueryCacheのonErrorはバックグラウンドの再取得で失敗したときにも呼ばれます。すでにデータを表示している画面ではダイアログを出したくない、という場合はquery.state.data !== undefinedで分岐します。 2. onSuccessを描画かmutationに移す useQueryのonSuccessでやっていた処理は、たいてい次のどちらかに移せます。 取得したデータをstateにコピーしている → dataをそのまま使うか、selectで加工する 保存後の画面遷移や完了ダイアログ → そもそもmutationの結果なので、useMutationのonSuccessに書く 3. 残りが0になったらv5に上げる @deprecatedが付いているので、エディタ上では残っているonSuccess / onError / onSettledに取り消し線が出ます。typescript-eslintの@typescript-eslint/no-deprecatedのような、非推奨APIを検出するルールを使えば一覧にもできます。 すべて移し終えたらv5に上げます。v5のパッケージにはcodemodが同梱されていて(remove-overloads、is-loadingなど)、引数の形やisLoading → isPendingの名前の変更は自動で書き換えられます。metaの型の拡張は、v5ではRegisterを使う書き方に変わるので、ここだけ手で書き換えます。 declare module '@tanstack/react-query' { - interface QueryMeta { - errorMessage?: string - } - interface Register { - queryMeta: { errorMessage?: string } - } } よくある互換ラッパーはv4とずれる 他のライブラリの都合などで、先にv5へ上げなければならないこともあります。その場合は互換ラッパーで一時的にしのぐことになりますが、よく紹介される次の書き方は、v4と挙動がずれます。 よくある互換ラッパー(v4 とずれる) export function useQueryWithCallbacks(options) { const { onSuccess, onError, ...queryOptions } = options const result = useQuery(queryOptions) useEffect(() => { if (result.data !== undefined) onSuccess?.(result.data) }, [result.data]) useEffect(() => { if (result.error) onError?.(result.error) }, [result.error]) return result } onErrorはこれでもv4と同じ回数で呼ばれました。取得に失敗するたびに新しいErrorオブジェクトが入るので、errorの参照が毎回変わるからです。 ずれるのはonSuccessです。 ケース v4 よくある互換ラッパー 再取得して前回と同じ内容が返る 呼ばれる 呼ばれない キャッシュが新鮮な状態で別のコンポーネントがマウント 呼ばれない 呼ばれる キャッシュが古い状態で再マウント 再取得の完了後に呼ばれる マウント直後(再取得の前)に呼ばれる setQueryDataでキャッシュを書き換え 呼ばれない 呼ばれる StrictModeでキャッシュが新鮮な状態でマウント 呼ばれない 2回呼ばれる 原因は、dataの参照が「取得したかどうか」と一致しないことです。TanStack Queryは、取得結果が前回と同じなら前回のオブジェクトをそのまま使い回します(structural sharing)。そのため、再取得して同じ内容が返ってもdataは変わりません。逆に、キャッシュ済みのデータを持ってマウントすると、取得していなくても最初のeffectでdataが入っています。 onSuccessを「取得が終わったら一度だけ何かする」用途に使っていると、呼ばれなかったり、余分に呼ばれたりします。 v4と同じ挙動にするラッパー 次のラッパーは、v4と同じタイミング・同じ回数で呼ばれるようにしたものです。 useQueryWithCallbacks.ts import { useEffect, useRef } from 'react' import { useQuery, useQueryClient, type DefaultError, type QueryKey, type UseQueryOptions, } from '@tanstack/react-query' type LegacyCallbacks<TData, TError> = { onSuccess?: (data: TData) => void onError?: (error: TError) => void onSettled?: (data: TData | undefined, error: TError | null) => void } /** - v4 の useQuery と同じタイミングで onSuccess / onError / onSettled を呼ぶ移行用ラッパー。 - @deprecated v5 の書き方に移したら削除する */ export function useQueryWithCallbacks< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( options: UseQueryOptions<TQueryFnData, TError, TData, TQueryKey> & LegacyCallbacks<TData, TError>, ) { const { onSuccess, onError, onSettled, ...queryOptions } = options const queryClient = useQueryClient() const result = useQuery(queryOptions) // 最新のコールバックを ref に入れておく(effect の依存に入れると毎レンダー走るため) const callbacks = useRef({ onSuccess, onError, onSettled }) useEffect(() => { callbacks.current = { onSuccess, onError, onSettled } }) // 取得による成功(setQueryData ではない success)の dataUpdatedAt を記録する const { queryHash } = queryClient.defaultQueryOptions(queryOptions) const fetchedAt = useRef<number | undefined>(undefined) useEffect(() => { return queryClient.getQueryCache().subscribe((event) => { if (event.type !== 'updated' || event.query.queryHash !== queryHash) return if (event.action.type === 'success' && !event.action.manual) { fetchedAt.current = event.query.state.dataUpdatedAt } }) }, [queryClient, queryHash]) useEffect(() => { if (fetchedAt.current !== result.dataUpdatedAt) return fetchedAt.current = undefined callbacks.current.onSuccess?.(result.data as TData) callbacks.current.onSettled?.(result.data, null) }, [result.dataUpdatedAt]) // エラーは setQueryData で起きないので、errorUpdatedAt の変化だけ見ればよい const lastErrorUpdatedAt = useRef(result.errorUpdatedAt) useEffect(() => { if (result.errorUpdatedAt === lastErrorUpdatedAt.current) return lastErrorUpdatedAt.current = result.errorUpdatedAt callbacks.current.onError?.(result.error as TError) callbacks.current.onSettled?.(undefined, result.error) }, [result.errorUpdatedAt]) return result } 呼び出し側は、関数名を置き換えるだけでv4と同じように書けます。 - const { data } = useQuery({ - const { data } = useQueryWithCallbacks({ queryKey: ['user', id], queryFn: () => fetchUser(id), onError: (error) => openErrorDialog(error.message), }) 実装のポイント dataではなくdataUpdatedAt / errorUpdatedAtの変化で判定しています。データやエラーが更新された時刻なので、中身が同じでも取得が終われば変わります。これで「同じ内容の再取得で呼ばれない」問題を防げます。 キャッシュを読んだだけのときは呼びません。成功側は、次に説明する「取得が完了したときに記録した時刻」と一致したときだけ呼びます。エラー側は、マウント時のerrorUpdatedAtをrefの初期値にしておき、変わったときだけ呼びます。どちらも、マウント時点でキャッシュにあったデータやエラーでは呼ばれず、StrictModeでeffectが2回走っても2回目は呼ばれません。失敗したクエリを再マウントするケースもv4と比べ、マウント直後には呼ばれず、再取得も失敗したときに1回呼ばれる点まで一致しました。 setQueryDataによる更新は、QueryCacheのイベントで除外しています。dataUpdatedAtの変化だけで判定すると、setQueryDataでキャッシュを書き換えたときにもonSuccessが呼ばれてしまいます。mutationのonSuccessで楽観的更新をしているコードだと、意図しないダイアログが出ます。 v4がsetQueryDataでonSuccessを呼ばないのは、内部で更新の種類を見分けているからです。該当するのは、v4のQueryObserverの次の部分です。 @tanstack/query-core v4 queryObserver onQueryUpdate(action) { const notifyOptions = {} if (action.type === 'success') { notifyOptions.onSuccess = !action.manual } else if (action.type === 'error' && !isCancelledError(action.error)) { notifyOptions.onError = true } // ... } setQueryDataはmanual: trueを付けてsuccessをdispatchします。v5でもこの仕組みは同じで、QueryCacheをsubscribeすると、このactionを受け取れます。ラッパーでは「manualではないsuccess」が来たときのdataUpdatedAtをfetchedAtに記録し、描画後のresult.dataUpdatedAtと一致したときだけonSuccessを呼んでいます。 コールバックは描画後のeffectで呼んでいます。QueryCacheのリスナーの中で直接呼ばないのは、selectを使っている場合に加工後のdataを渡したいからです。リスナーが受け取るのはキャッシュの生データなので、result.dataが揃う描画後まで待っています。 「取得中だったかどうか」で判定する方法はやめました 最初は「直前の描画でisFetchingがtrueだったときだけonSuccessを呼ぶ」という判定を試しました。setQueryDataは取得を伴わないので、これでも見分けられそうに見えます。 ところが、テスト環境のactの中では「取得中」の描画と「取得完了」の描画が1回にまとめられ、再取得のonSuccessが呼ばれませんでした。actを使わない状態でも、Promise.resolve()ですぐに値を返すqueryFnで2回再取得すると、onSuccessは1回も呼ばれませんでした(v4では2回呼ばれます)。取得がすぐ終わると、Reactが2回分の描画を1回にまとめてしまうためです。モックやローカルのデータを返すqueryFnでは普通に起こるので、描画の回数に依存する判定はやめ、QueryCacheのイベントで見分ける方法にしました。 v4と並べて検証した結果 v4のuseQueryと、このラッパーを同じシナリオで動かし、onSuccess / onErrorが呼ばれた回数を数えました。比較のため、よくある互換ラッパーと、QueryCacheのイベントを使わずdataUpdatedAtだけで判定した書き方も並べています。テストはactあり(Testing Libraryの標準)と、IS_REACT_ACT_ENVIRONMENT = falseにして描画をまとめない状態の両方で実行しました。表はactなしの結果です。 シナリオ v4 よくある互換ラッパー dataUpdatedAtだけで判定 このラッパー 初回取得が成功 1 1 1 1 再取得で同じ内容が返る 2 1 2 2 キャッシュが新鮮な状態で別コンポーネントがマウント +0 +1 +0 +0 キャッシュが古い状態で再マウント(取得完了前 / 後) +0 / +1 +1 / +1 +0 / +1 +0 / +1 同じクエリを2コンポーネントで使用 2 2 2 2 setQueryDataで書き換え +0 +1 +1 +0 取得中にsetQueryData 1 2 2 1 取得失敗 → 再取得も失敗(onError) 1 → 2 1 → 2 1 → 2 1 → 2 retry: 2で3回とも失敗(onError) 1 1 1 1 StrictModeで初回取得が成功 1 1 1 1 StrictModeでキャッシュが新鮮な状態でマウント 0 2 0 0 すぐに値を返すqueryFnで初回取得+再取得2回 3 1 3 3 selectを指定したときにonSuccessへ渡る値 加工後 加工後 加工後 加工後 このラッパーは、すべてのシナリオでv4と同じ結果になりました。 まとめ v4には、コールバックの@deprecated、QueryCacheのグローバルなコールバック、metaがそろっている。v4のうちにコールバックを移してからv5に上げれば、互換ラッパーはいらない 先にv5へ上げるためにラッパーを使う場合、useEffectでdataを監視するよくある書き方は、同じ内容の再取得・キャッシュからのマウント・setQueryData・StrictModeなどでv4とずれる dataUpdatedAt / errorUpdatedAtの変化で判定し、setQueryDataによる更新をQueryCacheのイベントで除外すると、v4と同じ結果になった。ただし一時的な手段として使う v5に上げたいけれどコールバックの書き換えが重くて止まっている、というプロジェクトの参考になればうれしいです。

    2 likes

Achievements

Awards earned by the repositories TanStack owns. Select an award to embed its badge.

Gold
11st place finishes
Silver
02nd place finishes
Bronze
23rd place finishes
Awarded repositories
1Owned by TanStack now

Repositories

Ordered by gold, then silver, then bronze.

  1. queryBest: 1st by stars, 309 stars102

Every award

Top-three finishes of these repositories in completed trending days and weeks, newest first.

  1. query
  2. query
  3. query

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.