Skip to content

Commit 90251b4

Browse files
authored
chore: Update React Compiler adoption guidelines (#1011)
This file is automatically synced from the `shared-configs` repository. Source: https://github.com/doist/shared-configs/blob/main/
1 parent 135a3cb commit 90251b4

File tree

1 file changed

+230
-48
lines changed

1 file changed

+230
-48
lines changed

docs/react-compiler.md

Lines changed: 230 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ A quick way to identify them is to install the [React Compiler Marker VSCode ext
3232

3333
Once fixed, the file's entry will be removed from `.react-compiler.rec.json`.
3434

35+
> **For LLM agents:** If a file is not in `.react-compiler.rec.json`, do not add `useMemo`, `useCallback`, or `React.memo` — the compiler handles memoization automatically. See [For LLM agents](#for-llm-agents) for the full workflow.
36+
3537
## Common errors
3638

3739
### Ref access during render
@@ -95,8 +97,6 @@ if (!storeRef.current) {
9597
const [storeInstance] = useState<TaskHierarchyStore>(() => store ?? createTaskHierarchyStore())
9698
```
9799

98-
**Note:** The lazy initializer captures the initial prop value. If `store` is initially undefined but becomes defined later, `storeInstance` will remain the created store. This matches the original ref behavior and is typically intentional.
99-
100100
**Before (one-time value computation):**
101101

102102
```typescript
@@ -208,7 +208,7 @@ function ProjectBoardView({ showCompleted: showCompletedProp }) {
208208
>
209209
> Reason: (BuildHIR::node.lowerReorderableExpression) Expression type `OptionalMemberExpression` cannot be safely reordered
210210
211-
The compiler can't safely reorder default parameter values that reference other parameters. This applies to both regular member access (`task.id`) and optional chaining (`task?.id`), since both depend on a sibling parameter being evaluated first.
211+
The compiler can't safely reorder default parameter values that reference other parameters.
212212

213213
**Before:**
214214

@@ -409,7 +409,7 @@ const hasCompletedTasks = useMemo(() => {
409409

410410
> Reason: Hooks must always be called in a consistent order, and may not be called conditionally. See the Rules of Hooks (https://react.dev/warnings/invalid-hook-call-warning)
411411
412-
The `store?.useState()` pattern conditionally calls a hook - when `store` is `undefined`, the hook isn't called; when defined, it is. This violates the Rules of Hooks which require hooks to be called unconditionally in the same order on every render.
412+
The `store?.useState()` pattern conditionally calls a hook, violating the Rules of Hooks.
413413

414414
**Before:**
415415

@@ -498,7 +498,7 @@ const _logout = useEvent(() => {
498498

499499
The inner function declaration is hoisted within its scope, allowing self-reference without depending on the outer variable. This pattern works for both `useEvent` and `useCallback`.
500500

501-
**Warning: Stale closures with async operations.** The inner function captures state values when the outer function is invoked. If the inner function is called later via `setTimeout`, it will see stale values. Use `useRef` instead for values read across async callbacks (like retry counters), since `.current` is read at access time rather than captured in the closure.
501+
**Warning:** The inner function captures state values at invocation time. For values read across async callbacks (like retry counters), use `useRef` instead.
502502

503503
#### Avoiding self-reference entirely
504504

@@ -529,7 +529,206 @@ useEffect(
529529
)
530530
```
531531

532-
### Optional chaining in try/catch blocks
532+
### Try/catch blocks
533+
534+
React Compiler has limited support for try/catch statements. Several patterns cause violations:
535+
536+
> **Note:** A fix for some of these limitations has been merged and may be available in a future compiler release. See [facebook/react#35606](https://github.com/facebook/react/pull/35606).
537+
538+
#### Try-catch-finally
539+
540+
> Reason: (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause
541+
542+
The `finally` clause causes compiler violations. Remove `finally` and explicitly handle cleanup in all code paths.
543+
544+
**Before:**
545+
546+
```typescript
547+
async function handleSubmit(event: React.FormEvent) {
548+
event.preventDefault()
549+
setIsSubmitting(true)
550+
551+
try {
552+
const response = await fetch('/api/endpoint', { method: 'POST' })
553+
if (!response.ok) {
554+
setError('Request failed')
555+
return
556+
}
557+
setSuccess(true)
558+
} catch {
559+
setError('Unknown error')
560+
} finally {
561+
setIsSubmitting(false)
562+
}
563+
}
564+
```
565+
566+
**After:**
567+
568+
```typescript
569+
async function handleSubmit(event: React.FormEvent) {
570+
event.preventDefault()
571+
setIsSubmitting(true)
572+
573+
try {
574+
const response = await fetch('/api/endpoint', { method: 'POST' })
575+
if (!response.ok) {
576+
setError('Request failed')
577+
setIsSubmitting(false)
578+
return
579+
}
580+
setSuccess(true)
581+
setIsSubmitting(false)
582+
} catch {
583+
setError('Unknown error')
584+
setIsSubmitting(false)
585+
}
586+
}
587+
```
588+
589+
#### Try without catch
590+
591+
> Reason: (BuildHIR::lowerStatement) Handle TryStatement without a catch clause
592+
593+
Try statements must have a `catch` clause. A `try { } finally { }` without `catch` is not supported. Additionally, since `finally` itself can cause violations (see above), consider removing it entirely.
594+
595+
**Before:**
596+
597+
```typescript
598+
useEffect(function loadData() {
599+
;(async () => {
600+
try {
601+
const data = await fetchData()
602+
setState(data)
603+
} finally {
604+
setIsLoading(false)
605+
}
606+
})().catch(() => setError('Failed'))
607+
}, [])
608+
```
609+
610+
**After:**
611+
612+
```typescript
613+
useEffect(function loadData() {
614+
;(async () => {
615+
try {
616+
const data = await fetchData()
617+
setState(data)
618+
setIsLoading(false)
619+
} catch {
620+
setError('Failed')
621+
setIsLoading(false)
622+
}
623+
})()
624+
}, [])
625+
```
626+
627+
#### ThrowStatement inside try/catch
628+
629+
> Reason: ThrowStatement inside try/catch not yet supported
630+
631+
Throwing errors inside try blocks causes violations. Handle errors directly instead of using throw.
632+
633+
**Before:**
634+
635+
```typescript
636+
try {
637+
const response = await fetch(url)
638+
if (!response.ok) {
639+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
640+
}
641+
return await response.json()
642+
} catch (error) {
643+
console.error(error)
644+
setLoadState('error')
645+
}
646+
```
647+
648+
**After:**
649+
650+
```typescript
651+
try {
652+
const response = await fetch(url)
653+
if (!response.ok) {
654+
// Handle error directly instead of throwing
655+
console.error(`HTTP Error: ${response.status} ${response.statusText}`)
656+
setLoadState('error')
657+
return
658+
}
659+
return await response.json()
660+
} catch (error) {
661+
console.error(error)
662+
setLoadState('error')
663+
}
664+
```
665+
666+
#### Redundant try/catch
667+
668+
When calling a function that already handles errors internally and returns a safe fallback, wrapping it in another try/catch is redundant and may cause violations.
669+
670+
**Before:**
671+
672+
```typescript
673+
// getLocalStorageValue already has internal try/catch, returns undefined on error
674+
function getLocalStorageValue<T>(key: string): T | undefined {
675+
try {
676+
const item = localStorage.getItem(key)
677+
return item ? JSON.parse(item) : undefined
678+
} catch {
679+
return undefined
680+
}
681+
}
682+
683+
const [value, setValue] = useState<T>(() => {
684+
try {
685+
return getLocalStorageValue(key) ?? defaultValue
686+
} catch {
687+
return defaultValue
688+
}
689+
})
690+
```
691+
692+
**After:**
693+
694+
```typescript
695+
// getLocalStorageValue already handles errors, no outer try/catch needed
696+
const [value, setValue] = useState<T>(() => getLocalStorageValue(key) ?? defaultValue)
697+
```
698+
699+
#### Value blocks in try/catch (general pattern)
700+
701+
> Reason: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement
702+
703+
Conditional expressions (`? :`), logical operators (`&&`, `||`), nullish coalescing (`??`), and other "value blocks" inside try/catch cause violations. The general fix is to extract the try/catch logic into a helper function outside the component.
704+
705+
**Before:**
706+
707+
```typescript
708+
const value = useMemo(() => {
709+
try {
710+
return riskyOperation() ?? fallback
711+
} catch {
712+
return fallback
713+
}
714+
}, [deps])
715+
```
716+
717+
**After:**
718+
719+
```typescript
720+
function safeRiskyOperation(deps: Deps) {
721+
try {
722+
return riskyOperation(deps) ?? fallback
723+
} catch {
724+
return fallback
725+
}
726+
}
727+
728+
const value = useMemo(() => safeRiskyOperation(deps), [deps])
729+
```
730+
731+
#### Optional chaining in try/catch blocks
533732

534733
> Reason: Support value blocks (conditional, logical, optional chaining, etc) within a try/catch statement
535734
@@ -598,7 +797,7 @@ useEffect(function assignMessageHandler() {
598797
return () => {
599798
worker.onmessage = null
600799
}
601-
// getWorker is non-reactive (only accesses a stable ref), so it's safe to omit from deps
800+
// getWorker only accesses a stable ref
602801
}, [])
603802
```
604803

@@ -655,7 +854,6 @@ function PublicRouteWrapper({ children }: { children: React.ReactNode }) {
655854
const authenticatedUser = localStorage.getItem('User')
656855

657856
if (authenticatedUser) {
658-
// Violation: DOM mutation during render
659857
window.location.href = '/app'
660858
return null
661859
}
@@ -720,9 +918,20 @@ useLayoutEffect(
720918
)
721919
```
722920

723-
## Identifying violations and verifying fixes (for LLMs)
921+
## For LLM agents
922+
923+
When working on React components or hooks in this codebase, follow this workflow:
924+
925+
### 1. Check if the file needs attention
724926

725-
When fixing violations programmatically, first identify modules with violations by checking `.react-compiler.rec.json`, then use the tracker CLI with `--show-errors` to see the exact errors:
927+
Look up the file in `.react-compiler.rec.json`:
928+
929+
- **Not listed** → Compiler is optimizing it. Do NOT add `useMemo`, `useCallback`, or `React.memo`.
930+
- **Listed with errors** → Continue to step 2.
931+
932+
### 2. Identify violations
933+
934+
Run the tracker with `--show-errors` to see exact errors:
726935

727936
```bash
728937
npx @doist/react-compiler-tracker --check-files --show-errors src/path/to/file.tsx
@@ -739,19 +948,21 @@ Detailed errors:
739948
- src/path/to/file.tsx: Line 28: Existing memoization could not be preserved (x2)
740949
```
741950

742-
Parse the output to extract error reasons (e.g., "Cannot access refs during render") and line numbers to plan the fix.
951+
Parse the output to extract error reasons and line numbers to plan the fix.
952+
953+
### 3. Fix violations
743954

744-
Once the fix is applied, verify it using one of the following methods:
955+
Use the patterns in [Common errors](#common-errors) to fix each violation. Focus on making the code compiler-compatible rather than adding more manual memoization.
745956

746-
**1. CLI Tool (recommended)**
957+
### 4. Verify the fix
747958

748-
Run the tracker with `--check-files` to verify changes against the records file:
959+
**CLI Tool (recommended)**
749960

750961
```bash
751962
npx @doist/react-compiler-tracker --check-files src/path/to/file.tsx
752963
```
753964

754-
The tool compares the current errors against `.react-compiler.rec.json` and reports changes:
965+
The tool compares current errors against `.react-compiler.rec.json`:
755966

756967
- **Errors increased** (exit code 1):
757968

@@ -770,9 +981,7 @@ The tool compares the current errors against `.react-compiler.rec.json` and repo
770981
771982
- **No changes** (exit code 0): No output about changes, just the check summary.
772983
773-
This is faster than `--overwrite` as it only checks the specified files rather than the entire codebase.
774-
775-
**2. Babel with Inline Logger (alternative)**
984+
**Babel with Inline Logger (alternative)**
776985
777986
Run a Node script that uses Babel's API with a custom logger to see exact errors:
778987
@@ -796,39 +1005,12 @@ require('@babel/core').transformFileSync('src/path/to/file.tsx', {
7961005
"
7971006
```
7981007

799-
This outputs the exact reason and location for each violation, regardless of repo-specific logging configuration.
800-
801-
**3. Transpiled Code Inspection**
802-
803-
Successfully optimized code will include `react-compiler-runtime` imports and compiler-generated memoization:
804-
805-
```typescript
806-
// Source
807-
function TaskList({ tasks }) {
808-
const sorted = tasks.toSorted((a, b) => a.order - b.order)
809-
return <List items={sorted} />
810-
}
811-
812-
// Transpiled (successfully optimized)
813-
import { c as _c } from "react-compiler-runtime";
814-
function TaskList({ tasks }) {
815-
const $ = _c(2);
816-
let sorted;
817-
if ($[0] !== tasks) {
818-
sorted = tasks.toSorted((a, b) => a.order - b.order);
819-
$[0] = tasks;
820-
$[1] = sorted;
821-
} else {
822-
sorted = $[1];
823-
}
824-
return <List items={sorted} />;
825-
}
826-
```
1008+
**Transpiled Code Inspection**
8271009

828-
Key indicators of successful optimization:
1010+
Successfully optimized code includes these patterns:
8291011

8301012
- `import { c as _c } from "react-compiler-runtime"` at the top
8311013
- `const $ = _c(N)` where N is the number of memo slots
8321014
- Conditional blocks checking `$[n] !== value` for cache invalidation
8331015

834-
If the transpiled output lacks these patterns and looks unchanged from the source, the component was not optimized due to a violation.
1016+
If the transpiled output lacks these patterns, the component was not optimized.

0 commit comments

Comments
 (0)