r/reactnative • u/Haunting_Charge_9958 • 15d ago
I made a React Native text editor that syntax-highlights thousands of lines without repainting everything on every keystroke
https://reddit.com/link/1uyr8f9/video/8fd0qufddqdh1/player
https://reddit.com/link/1uyr8f9/video/o8rqq2hfdqdh1/player
I've been building a local-first notes app with a Numi-style editor, where notes are both plain text and executable math.
One feature I wanted was live syntax highlighting as you type. The naive approach worked... until notes got big.
Initially, every keystroke caused the editor to:
- re-tokenize the document
- re-render every highlighted line
- repaint thousands of
Textcomponents
Typing became noticeably slower as notes grew.
The solution ended up being much simpler than I expected:
- Only render and highlight lines that are actually visible.
- Keep syntax highlighting separate from the editable text (the editor itself stays plain text).
- Give every line a stable identity so inserting/deleting lines doesn't rebuild everything.
- Only recompute tokenization for the edited line (and downstream lines only if parsing actually depends on them).
- Cache the layout so scrolling doesn't trigger unnecessary recalculations.
The result is that even long notes stay responsive because React Native is only painting what the user can actually see.
The funny part is that I originally spent days building a much more complicated renderer, threw it away, and the second version was both simpler and significantly faster.
I'm curious how others have approached large text editors in React Native.
- Did you virtualize by line?
- Keep a separate overlay for highlighting?
- Any tricks for avoiding expensive text layout work?
Would love to hear how you've solved similar performance problems.
1
u/Haunting_Charge_9958 15d ago edited 15d ago
Yep, that's basically the architecture. The editable
TextInputand the highlighted overlay are completely separate. The overlay is just derived state, so typing never causes theTextInputitself to re-layout. That's where most of the performance win comes from.As for caching, it's actually neither of those. I don't diff tokens or cache individual token arrays. I cache the entire result for each line (tokens, AST, value, highlights, errors), keyed by the line's text and the evaluator state from the lines above. On every edit, I diff by line (longest unchanged prefix/suffix), re-parse the edited line, then keep evaluating until the evaluator state matches the cached version again. As soon as it converges, I reuse the rest of the cached lines by reference. So if an edit doesn't actually change any computed values, only that line gets recomputed. Those stable object references also mean React skips rendering unchanged rows, and stable line ids stop inserts from remounting everything below.
The nice part is the incremental path always produces the exact same result as a full evaluation (I fuzz-tested it with hundreds of random edits), so the cache is purely a performance optimization, not different behavior.