|
| 1 | +# React Compiler |
| 2 | + |
| 3 | +[React Compiler](https://react.dev/learn/react-compiler) is an official compiler from the React team that automatically optimizes components and hooks. It analyzes your code and inserts `useMemo`, `useCallback`, and `React.memo` equivalents where beneficial, eliminating the need for manual optimization. |
| 4 | + |
| 5 | +We are incrementally adopting the compiler across all of our React codebases, where it is enabled but not all code may be compliant yet. As there are real performance risks if compiler violations are re-introduced, especially in cases where manual optimizations have been removed, we've put safeguards in place to prevent them from happening. |
| 6 | + |
| 7 | +## Violation tracking |
| 8 | + |
| 9 | +We use [`@doist/react-compiler-tracker`](https://github.com/Doist/react-compiler-tracker) to track modules that the compiler cannot optimize. |
| 10 | + |
| 11 | +Violations are recorded in a [`.react-compiler.rec.json`](https://github.com/Doist/todoist-web/blob/main/.react-compiler.rec.json), where each entry tracks the number of violations in that file: |
| 12 | + |
| 13 | +```json |
| 14 | +{ |
| 15 | + "recordVersion": 1, |
| 16 | + "react-compiler-version": "1.0.0", |
| 17 | + "files": { |
| 18 | + "src/path/to/file.tsx": { |
| 19 | + "CompileError": 3 |
| 20 | + } |
| 21 | + } |
| 22 | +} |
| 23 | +``` |
| 24 | + |
| 25 | +We leverage [lint-staged](https://github.com/lint-staged/lint-staged) to automatically update the records file on commit. If the numbers of errors are increased, the commit is blocked until the errors either go back to their previous levels, or if the records file is explicitly re-created. The same check is also run on CI. |
| 26 | + |
| 27 | +## Identifying and fixing violations |
| 28 | + |
| 29 | +When modifying a file with violations, consider fixing them so we can take advantage of the compiler's optimizations. |
| 30 | + |
| 31 | +A quick way to identify them is to install the [React Compiler Marker VSCode extension](https://marketplace.visualstudio.com/items?itemName=blazejkustra.react-compiler-marker), which highlights your components and hooks with ✨ or 🚫 emojis in real time. |
| 32 | + |
| 33 | +Once fixed, the file's entry will be removed from `.react-compiler.rec.json`. |
| 34 | + |
| 35 | +## Common errors |
| 36 | + |
| 37 | +### Ref access during render |
| 38 | + |
| 39 | +> Reason: Cannot access refs during render |
| 40 | +> |
| 41 | +> React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the current property) during render can cause your component not to update as expected (<https://react.dev/reference/react/useRef>) |
| 42 | +
|
| 43 | +Assigning to refs during render is a violation as it's a side effect. The fix depends on how the ref was being used, but an example is that if we were using it to prevent a state value from being used to re-create callbacks, consider using `useEvent` instead. |
| 44 | + |
| 45 | +**Before:** |
| 46 | + |
| 47 | +```typescript |
| 48 | +const tasksBySectionIdRef = useRef(tasksBySectionId) |
| 49 | +const splitGroupsRef = useRef(splitGroups) |
| 50 | + |
| 51 | +// Assigning during render - violation |
| 52 | +tasksBySectionIdRef.current = tasksBySectionId |
| 53 | +splitGroupsRef.current = splitGroups |
| 54 | + |
| 55 | +const handleTaskEditClick = useCallback( |
| 56 | + (task: Task) => { |
| 57 | + const group = splitGroupsRef.current |
| 58 | + ? Object.entries(tasksBySectionIdRef.current).find(...) |
| 59 | + : null |
| 60 | + onEditItem(group?.[0] ?? task.section_id, task.id) |
| 61 | + }, |
| 62 | + [onEditItem], |
| 63 | +) |
| 64 | +``` |
| 65 | + |
| 66 | +**After:** |
| 67 | + |
| 68 | +```typescript |
| 69 | +import { useEvent } from 'react-use-event-hook' |
| 70 | + |
| 71 | +const handleTaskEditClick = useEvent((task: Task) => { |
| 72 | + const group = splitGroups |
| 73 | + ? Object.entries(tasksBySectionId).find(...) |
| 74 | + : null |
| 75 | + onEditItem(group?.[0] ?? task.section_id, task.id) |
| 76 | +}) |
| 77 | +``` |
| 78 | + |
| 79 | +### Mismatched `useMemo` dependencies |
| 80 | + |
| 81 | +> Reason: Existing memoization could not be preserved |
| 82 | +> |
| 83 | +> React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was \[...\], but the source dependencies were \[...\]. Inferred less specific property than source |
| 84 | +
|
| 85 | +Typically, this is caused by the use of the optional chaining operator, as the compiler infers the parent object as the actual dependency. Our options here are to either extract the optional-chained property into a variable, or use the parent object as a dependency. We can also consider removing the manual memoization. |
| 86 | + |
| 87 | +**Before:** |
| 88 | + |
| 89 | +```typescript |
| 90 | +const splitGroups = useProjectGrouping({ projectId }) |
| 91 | + |
| 92 | +const tasksBySectionId = useMemo(() => { |
| 93 | + if (splitGroups?.uncompleted) { |
| 94 | + return splitGroups.uncompleted.reduce(...) |
| 95 | + } |
| 96 | + // ... |
| 97 | +}, [sortFn, splitGroups?.uncompleted]) // Optional chaining in deps |
| 98 | +``` |
| 99 | + |
| 100 | +**After:** |
| 101 | + |
| 102 | +```typescript |
| 103 | +const splitGroups = useProjectGrouping({ projectId }) |
| 104 | +const uncompletedGroups = splitGroups?.uncompleted |
| 105 | + |
| 106 | +const tasksBySectionId = useMemo(() => { |
| 107 | + if (uncompletedGroups) { |
| 108 | + return uncompletedGroups.reduce(...) |
| 109 | + } |
| 110 | + // ... |
| 111 | +}, [sortFn, uncompletedGroups]) // Extracted variable in deps |
| 112 | +``` |
| 113 | + |
| 114 | +### Mutating props |
| 115 | + |
| 116 | +> Reason: Support destructuring of context variables |
| 117 | +
|
| 118 | +React Compiler requires props to be treated as immutable, so they can't be reassigned or mutated. |
| 119 | + |
| 120 | +**Before:** |
| 121 | + |
| 122 | +```typescript |
| 123 | +function ProjectBoardView({ showCompleted }) { |
| 124 | + const isViewOnly = useSelectIsViewOnlyPublicProject(projectId) |
| 125 | + if (isViewOnly) { |
| 126 | + showCompleted = true // Violation: reassigning prop |
| 127 | + } |
| 128 | +} |
| 129 | +``` |
| 130 | + |
| 131 | +**After:** |
| 132 | + |
| 133 | +```typescript |
| 134 | +function ProjectBoardView({ showCompleted: showCompletedProp }) { |
| 135 | + const isViewOnly = useSelectIsViewOnlyPublicProject(projectId) |
| 136 | + const showCompleted = isViewOnly || showCompletedProp |
| 137 | +} |
| 138 | +``` |
| 139 | + |
| 140 | +### Default parameters for props |
| 141 | + |
| 142 | +> Reason: (BuildHIR::node.lowerReorderableExpression) Expression type `MemberExpression` cannot be safely reordered |
| 143 | +> |
| 144 | +> Reason: (BuildHIR::node.lowerReorderableExpression) Expression type `OptionalMemberExpression` cannot be safely reordered |
| 145 | +
|
| 146 | +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. |
| 147 | + |
| 148 | +**Before:** |
| 149 | + |
| 150 | +```typescript |
| 151 | +function DndTaskWrapper({ |
| 152 | + task, |
| 153 | + stableTaskId = task.id, // Violation: references sibling parameter |
| 154 | +}: Props) { |
| 155 | + // ... |
| 156 | +} |
| 157 | +``` |
| 158 | + |
| 159 | +**After:** |
| 160 | + |
| 161 | +```typescript |
| 162 | +function DndTaskWrapper({ task, stableTaskId: stableTaskIdProp }: Props) { |
| 163 | + const stableTaskId = stableTaskIdProp ?? task?.id |
| 164 | + // ... |
| 165 | +} |
| 166 | +``` |
| 167 | + |
| 168 | +### Computed property keys |
| 169 | + |
| 170 | +> Reason: (BuildHIR::lowerExpression) Expected Identifier, got `LogicalExpression` key in ObjectExpression |
| 171 | +> |
| 172 | +> Reason: (BuildHIR::lowerExpression) Expected Identifier, got `BinaryExpression` key in ObjectExpression |
| 173 | +
|
| 174 | +The compiler expects simple identifiers as property keys. If computation is required, first extract them into a variable. |
| 175 | + |
| 176 | +**Before:** |
| 177 | + |
| 178 | +```typescript |
| 179 | +return { |
| 180 | + ...all, |
| 181 | + [key ?? DEFAULT_SECTION_ID]: tasks, |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +**After:** |
| 186 | + |
| 187 | +```typescript |
| 188 | +const groupKey = key ?? DEFAULT_SECTION_ID |
| 189 | +return { |
| 190 | + ...all, |
| 191 | + [groupKey]: tasks, |
| 192 | +} |
| 193 | +``` |
| 194 | + |
| 195 | +### Loop variable reassignment |
| 196 | + |
| 197 | +> Reason: Destructure should never be Reassign as it would be an Object/ArrayPattern |
| 198 | +
|
| 199 | +Declaring a variable before a loop and reassigning it inside is a violation, as the compiler cannot safely track mutable variables. |
| 200 | + |
| 201 | +**Before:** |
| 202 | + |
| 203 | +```typescript |
| 204 | +const hasCompletedTasks = useMemo(() => { |
| 205 | + let ancestorType: AncestorType |
| 206 | + for (ancestorType in transformedState) { |
| 207 | + const ancestors = transformedState[ancestorType] |
| 208 | + // ... |
| 209 | + } |
| 210 | +}, [transformedState]) |
| 211 | +``` |
| 212 | + |
| 213 | +**After:** |
| 214 | + |
| 215 | +```typescript |
| 216 | +const hasCompletedTasks = useMemo(() => { |
| 217 | + for (const ancestorType in transformedState) { |
| 218 | + const ancestors = transformedState[ancestorType as AncestorType] |
| 219 | + // ... |
| 220 | + } |
| 221 | +}, [transformedState]) |
| 222 | +``` |
| 223 | + |
| 224 | +## Verifying fixes (for LLMs) |
| 225 | + |
| 226 | +When fixing violations programmatically, use one of these methods to verify the fix was successful: |
| 227 | + |
| 228 | +**1. CLI Tool (recommended)** |
| 229 | + |
| 230 | +Run the tracker to check if a file has violations: |
| 231 | + |
| 232 | +```bash |
| 233 | +npx @doist/react-compiler-tracker --check-files src/path/to/file.tsx |
| 234 | +``` |
| 235 | + |
| 236 | +A successful check produces no output. If violations remain, the tool reports the error count. |
| 237 | + |
| 238 | +**2. Babel with Inline Logger** |
| 239 | + |
| 240 | +Run a Node script that uses Babel's API with a custom logger to see exact errors: |
| 241 | + |
| 242 | +```bash |
| 243 | +node -e " |
| 244 | +require('@babel/core').transformFileSync('src/path/to/file.tsx', { |
| 245 | + presets: ['@babel/preset-typescript', ['@babel/preset-react', { runtime: 'automatic' }]], |
| 246 | + plugins: [['babel-plugin-react-compiler', { |
| 247 | + logger: { |
| 248 | + logEvent(filename, event) { |
| 249 | + if (event.kind === 'CompileError') { |
| 250 | + console.error('[CompileError]', filename); |
| 251 | + console.error('Reason:', event.detail?.reason); |
| 252 | + const loc = event.detail?.primaryLocation?.(); |
| 253 | + if (loc?.start) console.error('Location: Line', loc.start.line); |
| 254 | + } |
| 255 | + } |
| 256 | + } |
| 257 | + }]] |
| 258 | +}); |
| 259 | +" |
| 260 | +``` |
| 261 | + |
| 262 | +This outputs the exact reason and location for each violation, regardless of repo-specific logging configuration. |
| 263 | + |
| 264 | +**3. Transpiled Code Inspection** |
| 265 | + |
| 266 | +Successfully optimized code will include `react-compiler-runtime` imports and compiler-generated memoization: |
| 267 | + |
| 268 | +```typescript |
| 269 | +// Source |
| 270 | +function TaskList({ tasks }) { |
| 271 | + const sorted = tasks.toSorted((a, b) => a.order - b.order) |
| 272 | + return <List items={sorted} /> |
| 273 | +} |
| 274 | + |
| 275 | +// Transpiled (successfully optimized) |
| 276 | +import { c as _c } from "react-compiler-runtime"; |
| 277 | +function TaskList({ tasks }) { |
| 278 | + const $ = _c(2); |
| 279 | + let sorted; |
| 280 | + if ($[0] !== tasks) { |
| 281 | + sorted = tasks.toSorted((a, b) => a.order - b.order); |
| 282 | + $[0] = tasks; |
| 283 | + $[1] = sorted; |
| 284 | + } else { |
| 285 | + sorted = $[1]; |
| 286 | + } |
| 287 | + return <List items={sorted} />; |
| 288 | +} |
| 289 | +``` |
| 290 | +
|
| 291 | +Key indicators of successful optimization: |
| 292 | +
|
| 293 | +- `import { c as _c } from "react-compiler-runtime"` at the top |
| 294 | +- `const $ = _c(N)` where N is the number of memo slots |
| 295 | +- Conditional blocks checking `$[n] !== value` for cache invalidation |
| 296 | +
|
| 297 | +If the transpiled output lacks these patterns and looks unchanged from the source, the component was not optimized due to a violation. |
0 commit comments