diff --git a/.changeset/serious-papayas-smoke.md b/.changeset/serious-papayas-smoke.md
new file mode 100644
index 000000000..549c57cc6
--- /dev/null
+++ b/.changeset/serious-papayas-smoke.md
@@ -0,0 +1,5 @@
+---
+'slate-react': minor
+---
+
+Added redux-style useSlateSelector to improve and prevent unneccessary rerendering with the useSlate hook
diff --git a/packages/slate-react/src/components/slate.tsx b/packages/slate-react/src/components/slate.tsx
index b1af68856..52fd41de1 100644
--- a/packages/slate-react/src/components/slate.tsx
+++ b/packages/slate-react/src/components/slate.tsx
@@ -4,6 +4,10 @@ import { ReactEditor } from '../plugin/react-editor'
import { FocusedContext } from '../hooks/use-focused'
import { EditorContext } from '../hooks/use-slate-static'
import { SlateContext } from '../hooks/use-slate'
+import {
+ getSelectorContext,
+ SlateSelectorContext,
+} from '../hooks/use-slate-selector'
import { EDITOR_TO_ON_CHANGE } from '../utils/weak-maps'
import { useIsomorphicLayoutEffect } from '../hooks/use-isomorphic-layout-effect'
@@ -38,9 +42,15 @@ export const Slate = (props: {
return [editor]
})
+ const {
+ selectorContext,
+ onChange: handleSelectorChange,
+ } = getSelectorContext(editor)
+
const onContextChange = useCallback(() => {
onChange(editor.children)
setContext([editor])
+ handleSelectorChange(editor)
}, [onChange])
EDITOR_TO_ON_CHANGE.set(editor, onContextChange)
@@ -76,12 +86,14 @@ export const Slate = (props: {
}, [])
return (
-
-
-
- {children}
-
-
-
+
+
+
+
+ {children}
+
+
+
+
)
}
diff --git a/packages/slate-react/src/hooks/use-slate-selector.tsx b/packages/slate-react/src/hooks/use-slate-selector.tsx
new file mode 100644
index 000000000..720f58f3a
--- /dev/null
+++ b/packages/slate-react/src/hooks/use-slate-selector.tsx
@@ -0,0 +1,139 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useReducer,
+ useRef,
+} from 'react'
+import { Editor } from 'slate'
+import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'
+
+function isError(error: any): error is Error {
+ return error instanceof Error
+}
+
+type EditorChangeHandler = (editor: Editor) => void
+/**
+ * A React context for sharing the editor selector context in a way to control rerenders
+ */
+
+export const SlateSelectorContext = createContext<{
+ getSlate: () => Editor
+ addEventListener: (callback: EditorChangeHandler) => () => void
+}>({} as any)
+
+const refEquality = (a: any, b: any) => a === b
+
+/**
+ * use redux style selectors to prevent rerendering on every keystroke.
+ * Bear in mind rerendering can only prevented if the returned value is a value type or for reference types (e.g. objects and arrays) add a custom equality function.
+ *
+ * Example:
+ * ```
+ * const isSelectionActive = useSlateSelector(editor => Boolean(editor.selection));
+ * ```
+ */
+export function useSlateSelector(
+ selector: (editor: Editor) => T,
+ equalityFn: (a: T, b: T) => boolean = refEquality
+) {
+ const [, forceRender] = useReducer(s => s + 1, 0)
+ const context = useContext(SlateSelectorContext)
+ if (!context) {
+ throw new Error(
+ `The \`useSlateSelector\` hook must be used inside the component's context.`
+ )
+ }
+ const { getSlate, addEventListener } = context
+
+ const latestSubscriptionCallbackError = useRef()
+ const latestSelector = useRef<(editor: Editor) => T>(() => null as any)
+ const latestSelectedState = useRef((null as any) as T)
+ let selectedState: T
+
+ try {
+ if (
+ selector !== latestSelector.current ||
+ latestSubscriptionCallbackError.current
+ ) {
+ selectedState = selector(getSlate())
+ } else {
+ selectedState = latestSelectedState.current
+ }
+ } catch (err) {
+ if (latestSubscriptionCallbackError.current && isError(err)) {
+ err.message += `\nThe error may be correlated with this previous error:\n${latestSubscriptionCallbackError.current.stack}\n\n`
+ }
+
+ throw err
+ }
+ useIsomorphicLayoutEffect(() => {
+ latestSelector.current = selector
+ latestSelectedState.current = selectedState
+ latestSubscriptionCallbackError.current = undefined
+ })
+
+ useIsomorphicLayoutEffect(
+ () => {
+ function checkForUpdates() {
+ try {
+ const newSelectedState = latestSelector.current(getSlate())
+
+ if (equalityFn(newSelectedState, latestSelectedState.current)) {
+ return
+ }
+
+ latestSelectedState.current = newSelectedState
+ } catch (err) {
+ // we ignore all errors here, since when the component
+ // is re-rendered, the selectors are called again, and
+ // will throw again, if neither props nor store state
+ // changed
+ latestSubscriptionCallbackError.current = err
+ }
+
+ forceRender()
+ }
+
+ const unsubscribe = addEventListener(checkForUpdates)
+
+ checkForUpdates()
+
+ return () => unsubscribe()
+ },
+ // don't rerender on equalityFn change since we want to be able to define it inline
+ [addEventListener, getSlate]
+ )
+
+ return selectedState
+}
+
+/**
+ * Create selector context with editor updating on every editor change
+ */
+export function getSelectorContext(editor: Editor) {
+ const eventListeners = useRef([]).current
+ const slateRef = useRef<{
+ editor: Editor
+ }>({
+ editor,
+ }).current
+ const onChange = useCallback((editor: Editor) => {
+ slateRef.editor = editor
+ eventListeners.forEach((listener: EditorChangeHandler) => listener(editor))
+ }, [])
+
+ const selectorContext = useMemo(() => {
+ return {
+ getSlate: () => slateRef.editor,
+ addEventListener: (callback: EditorChangeHandler) => {
+ eventListeners.push(callback)
+ return () => {
+ eventListeners.splice(eventListeners.indexOf(callback), 1)
+ }
+ },
+ }
+ }, [eventListeners, slateRef])
+ return { selectorContext, onChange }
+}
diff --git a/packages/slate-react/src/index.ts b/packages/slate-react/src/index.ts
index efc26f5e1..f652ddd59 100644
--- a/packages/slate-react/src/index.ts
+++ b/packages/slate-react/src/index.ts
@@ -24,6 +24,7 @@ export { useFocused } from './hooks/use-focused'
export { useReadOnly } from './hooks/use-read-only'
export { useSelected } from './hooks/use-selected'
export { useSlate } from './hooks/use-slate'
+export { useSlateSelector } from './hooks/use-slate-selector'
// Plugin
export { ReactEditor } from './plugin/react-editor'