docs/solutions/developer-experience/2026-05-11-slate-v2-react-hooks-refs-lint-needs-real-render-fixes.md
react-hooks/refs and react-hooks/use-memo are not React Compiler-only rules.
Keeping reactHooks.configs.flat.recommended means Slate v2 code must stop
reading or writing React refs during render and stop using useMemo as a
one-shot constructor escape hatch.
bun lint failed on react-hooks/refs in selector, decoration, annotation,
widget, and example cleanup code.useRef was being used as a "latest value" container and updated directly in
render.EditableText passed attributes.ref through Slate render props, which the
rule flagged as "Passing a ref to a function may read its value during
render."useMemo(createThing, []) failed because use-memo expects the first
argument to be an inline function expression.useEffect failed
react-hooks/set-state-in-effect and added a pointless cascading render.react-hooks/refs with the compiler-specific rules was wrong. The
rule enforces core React ref semantics.react-hooks/use-memo and
react-hooks/preserve-manual-memoization was also wrong. Those rules catch
real manual memoization patterns that should be written as lazy state or
explicit dependency lists.Keep the recommended React Hooks preset and disable only the currently noisy
compiler mutation rule in .tmp/slate-v2/eslint.config.mjs:
rules: {
...reactHooks.configs.flat.recommended.rules,
'react-hooks/immutability': 'off',
}
For actual render-time ref access, replace React refs with stable hook-owned cells created by lazy state initializers:
const [cell] = useState(() => createGenericSelectorCell(equalityFn));
cell.equalityFn = equalityFn;
Use that pattern for selector/external-store internals where the current value
must be visible to same-render reads without touching ref.current.
Use lazy state for one-shot mutable constructors:
const [controllerState] = useState(createEditableInputControllerState);
Keep useMemo for values that are genuinely derived from dependencies, and use
an inline function expression:
const inputController = useMemo(
() =>
createEditableInputController({
preferModelSelectionForInputRef,
state: controllerState,
}),
[controllerState, preferModelSelectionForInputRef],
);
For selector-local memory that must affect the selected value itself, keep the memory inside a stable selector factory instead of using a render-time ref or an effect mirror:
const createHistoryRootSelector = () => {
let lastRoot = 'main';
return (state) => {
const root = selectSelectionRoot(state);
if (root) {
lastRoot = root;
}
return root ?? lastRoot;
};
};
const historyRootSelector = useMemo(() => createHistoryRootSelector(), []);
const historyRoot = useSlateRuntimeState(historyRootSelector, {
deps: [historyRootSelector],
});
This shape is valid only when the mutable value belongs to the selector's external-store projection. Do not use it as a general replacement for React state.
For stores that read app-owned lists, keep a stable source callback and refresh after input changes:
const [widgetsCell] = useState(() => ({ current: widgets }));
const store = useMemo(
() =>
createSlateWidgetStore(editor, () => widgetsCell.current, annotationStore),
[annotationStore, editor, widgetsCell],
);
useEffect(() => {
widgetsCell.current = widgets;
store.refresh();
}, [store, widgets, widgetsCell]);
For effect-only cleanup refs, update refs inside an effect instead of render, or delete unused refs.
The one valid local exemption is the Slate render-prop API. Passing
attributes.ref to renderText / renderPlaceholder is not a ref.current
read; it is the public render contract. Keep that exemption narrow and comment
it at the exact call site.
The React refs rule catches mutable React ref access during render because React does not track ref writes as render inputs. Stable cells avoid the specific React ref contract while preserving the existing external-store cache behavior.
The render-prop exemption stays local because the public Slate API really does hand callback refs to user render functions. Treating that as a global rule disable would hide real ref bugs elsewhere.
react-hooks/refs, react-hooks/use-memo, or
react-hooks/preserve-manual-memoization as compiler-specific.useState cells over useRef when a hook-owned mutable cache must
be read during render.useState over useMemo(fn, []) for one-shot constructors.useMemo(() => createSelector(), []) call over ref reads or effect
mirrors.react-hooks/exhaustive-deps; use lazy state cells when a
stable instance is intentional.immutability and rare local
incompatible-library cases.bun lint, package typecheck, and hook/render tests after changing
shared React hooks.