| name | react-best-practices |
| description | React performance and correctness rules tuned for silkdown — a CodeMirror 6 wrapper library, not a Next.js app. Auto-trigger when editing the React adapter (packages/react/src/SilkdownEditor.tsx, packages/react/src/index.ts), reviewing or refactoring any React component or hook in this repo, optimizing bundle budgets in size-limit, debugging unnecessary re-renders or stale closures around the EditorView, or adding lazy-loaded widgets (math/mermaid/table). Also fires on: "React perf", "useEffect", "memoization", "stale ref", "stale closure", "rerender", "useMemo", "useCallback", "forwardRef", "imperative handle", "bundle size", "tree-shaking", "barrel imports", "React.lazy", "controlled vs uncontrolled". |
React Best Practices — silkdown edition
Adapted from Vercel Engineering's React performance guide. Aggressively pruned for this repo:
- Removed: every Next.js / SSR / hydration / RSC / server-action /
next/script / next/dynamic rule, every client-swr-* and client-localstorage-* rule, every rendering-* rule (SVG animation, content-visibility, hydration scripts, resource hints, script tags — none of which apply to a CM6 wrapper), and the full js-* micro-optimization category (general perf tips, not React; profile decorate/* hot paths before optimizing them).
- Kept: rules that affect either (a) the React adapter wrapping
EditorView, or (b) library bundle budgets enforced by pnpm size.
When to apply
Read these rules when:
- Touching
packages/react/src/SilkdownEditor.tsx or any future framework adapter
- Adding lazy-loaded widgets (image/table/code/math/mermaid) — these affect both
bundle-* rules and consumer bundle size
- Reviewing PRs for unnecessary re-renders, stale closures, or
useEffect mistakes
- Auditing
packages/core/dist/index.js against its 30 KB limit or packages/react/dist/index.js against its 5 KB limit (pnpm size)
- Building example apps in
examples/ that demonstrate idiomatic library usage
Rule categories (priority order)
| Priority | Category | Why it survived the prune |
|---|
| 1 | Bundle Size (bundle-*) | The library has hard size-limit budgets; tree-shaking and lazy loading are non-negotiable. |
| 2 | Re-render Optimization (rerender-*) | The React adapter must not invalidate EditorView on parent renders, and consumers will copy our patterns. |
| 3 | Advanced (advanced-*) | All four of these directly describe the CM6-wrapper pattern (refs for handlers, init-once, useLatest, effect-event deps). |
| 4 | Async (async-*) | A few rules apply to tests and lazy-widget loading. |
| 5 | Client-side (client-*) | Two rules about DOM event listeners — relevant because CM6 attaches them. |
Quick reference
1. Bundle Size (CRITICAL)
bundle-barrel-imports — Import directly; barrel files defeat tree-shaking and break our 30 KB / 5 KB budgets.
bundle-dynamic-imports — React.lazy for heavy widgets (math, mermaid, large code-fence renderers).
bundle-conditional — Load widget renderers only when their feature is activated by silkdown() options.
bundle-preload — Preload widget code on hover/focus for perceived speed.
2. Re-render Optimization
rerender-defer-reads — Don't subscribe to state only used in callbacks.
rerender-memo — Extract expensive work into memoized components.
rerender-memo-with-default-value — Hoist default non-primitive props (e.g. default extensions={[]}).
rerender-dependencies — Use primitive dependencies in effects; the adapter's value prop dep is a string by design.
rerender-derived-state — Subscribe to derived booleans, not raw values.
rerender-derived-state-no-effect — Derive state during render, not in effects.
rerender-functional-setstate — Functional setState for stable callbacks.
rerender-lazy-state-init — Pass a function to useState for expensive init.
rerender-simple-expression-in-memo — Avoid useMemo for primitive-returning expressions.
rerender-split-combined-hooks — Split hooks with independent dependencies.
rerender-move-effect-to-event — Put interaction logic in event handlers (CM6 already gives us update listeners — use them, don't re-derive in effects).
rerender-transitions — Use startTransition for non-urgent updates.
rerender-use-deferred-value — Defer expensive renders to keep input responsive.
rerender-use-ref-transient-values — Refs for transient frequent values (this is exactly how viewRef works in SilkdownEditor.tsx).
rerender-no-inline-components — Don't define components inside components.
3. Advanced (all directly describe the CM6 wrapper pattern)
advanced-effect-event-deps — Don't put useEffectEvent results in effect deps.
advanced-event-handler-refs — Store event handlers in refs. This is exactly how onChangeRef works in SilkdownEditor.tsx — keeps onChange referentially stable so the editor doesn't recreate when the parent re-renders.
advanced-init-once — Initialize the editor once per mount (empty deps array on the mounting effect).
advanced-use-latest — useLatest pattern for stable callback refs.
4. Async
async-cheap-condition-before-await — Check cheap sync conditions before awaiting (e.g. compare existing doc string before dispatching).
async-defer-await — Move await into the branch where it's actually used.
async-parallel — Use Promise.all() for independent operations (matters in tests, builds, and any future multi-language code highlighter init).
5. Client-side
client-event-listeners — Deduplicate global event listeners. CM6 attaches DOM listeners; if we add document-level listeners (e.g. for clipboard hooks) they must be deduped.
client-passive-event-listeners — Use passive listeners for scroll. Relevant for any scroll-syncing widgets.
How to use
Each rule lives in rules/<rule-name>.md with: brief explanation, incorrect example, correct example, additional context. Open the file matching the situation — these are not all loaded eagerly.
Why some rules are gone
If you came looking for a rule and don't see it:
rendering-hydration-*, rendering-script-defer-async, rendering-resource-hints: This is a library, not an SSR app. Hydration and <script>/<link> tags don't apply.
client-swr-dedup, client-localstorage-schema: We don't fetch and we don't persist user data.
bundle-defer-third-party: We have no analytics/logging dependencies.
async-suspense-boundaries, async-dependencies: Suspense isn't used; the editor mounts synchronously.
rendering-svg-precision, rendering-animate-svg-wrapper: No SVG.
- All
js-* rules: General JS micro-perf, not React-specific. Profile decorate/* hot paths before optimizing them — speculative wins aren't worth the readability cost.
If a removed rule turns out to apply later (e.g. we add an SSR-aware adapter), restore it from the upstream Vercel guide.