Three Utility Hooks Inside rn-markdown-editor: useMergeState, useDebouncedInput, and useDisclosure
Previously:
The package ships three small but deliberate utility hooks alongside the editor components: useMergeState, useDebouncedInput, and useDisclosure. None of them are complicated — but each one exists for a specific reason, and understanding why they're shaped the way they are makes them a lot easier to reach for correctly.
1. useMergeState — partial state updates without object replacement
The problem it solves
React's useState replaces state entirely on every update. When your state is a plain object with multiple keys, that means you have to spread the previous state manually every single time:
// Without useMergeState — error-prone boilerplate
const [state, setState] = useState({ name: "", email: "", age: 0 });
// You must spread every time or you'll wipe the other keys
setState(prev => ({ ...prev, name: "Alice" }));
Miss the spread once and you silently overwrite the rest of your state. This is a common source of hard-to-trace bugs — especially when multiple parts of a component are updating different slices of the same object independently.
What it does
useMergeState gives you a setter that behaves like the old class-based this.setState — it merges the incoming partial object into the existing state rather than replacing it:
export const useMergeState = <T extends Record<string, any>>(
initialState: T,
): [T, MergeState<T>] => {
const [value, setValue] = React.useState<T>(initialState);
const mergeState: MergeState<T> = React.useCallback((newState) => {
setValue((prevState) => ({
...prevState,
...(typeof newState === "function" ? newState(prevState) : newState),
}));
}, []);
return [value, mergeState];
};
Two things worth noting here:
- The setter is stable.
useCallbackwith an empty dependency array meansmergeStategets the same reference on every render. Components or hooks that receive it as a prop or dependency won't re-render just because the parent re-rendered. - It accepts a function or an object. Passing a function
(prev) => ({ ... })is important when the new value depends on the previous one — it avoids the stale-closure problem that comes from capturingstatedirectly in a callback.
When to use it
Reach for useMergeState any time your state is an object with more than one key that different parts of your UI update independently. If your state is a single primitive (string, boolean, number), plain useState is fine.
const [form, setForm] = useMergeState({ title: "", body: "", isDraft: true });
// Update one key — the rest stay untouched
setForm({ title: "New title" });
// Or use the function form when the new value depends on current state
setForm(prev => ({ isDraft: !prev.isDraft }));
2. useDebouncedInput — instant UI feedback, delayed side effects
The problem it solves
Search inputs are the classic case: you want the text field to update instantly as the user types (otherwise it feels broken), but you don't want to fire an API call on every single keystroke. The naive solution — a single debounced value — introduces visible lag in the input itself. Splitting the two concerns into separate values fixes that.
What it does
useDebouncedInput maintains two separate values in a single merged state object:
immediateValue— updated synchronously on every keystroke, bound directly to the input field.debouncedValue— updated after a configurable delay (default: 300ms), intended for side effects like search queries or API calls.
export const useDebouncedInput = (initialValue = "", delay = 300) => {
const [state, setState] = useMergeState<DebouncedInputState>({
immediateValue: initialValue,
debouncedValue: initialValue,
});
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const debouncedSetter = React.useMemo(
() => (value: string) => {
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setState({ debouncedValue: value });
}, delay);
},
[delay],
);
const handleInputChange = React.useCallback(
(value: string) => {
setState({ immediateValue: value });
debouncedSetter(value);
},
[debouncedSetter],
);
const clearDebounce = React.useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
}, []);
return { immediateValue, debouncedValue, setInput: handleInputChange, clearDebounce };
};
A few deliberate decisions here:
- Built on
useMergeState. Both values live in one state object so they're co-located, but partial updates mean settingimmediateValuenever touchesdebouncedValueand vice versa — no risk of overwriting the debounced value when the immediate one changes. - The timer is stored in a
ref, not state. Storing it in state would trigger a re-render every time the debounce timer was set or cleared, which would happen on every keystroke. Arefholds the timer ID without causing renders. debouncedSetteris memoized withuseMemo, notuseCallback. The function is recreated only whendelaychanges, which is almost never. This keeps the reference stable and preventshandleInputChange(which depends on it) from being rebuilt on every render.ReturnType<typeof setTimeout>is used instead ofnumberorNodeJS.Timeout. This is intentional cross-platform safety — the return type ofsetTimeoutdiffers between web and React Native's JS runtime, and letting TypeScript infer it from the function avoids having to conditionally type it per platform.clearDebounceis exposed. If you unmount the component or cancel an operation mid-flight, callclearDebounce()to prevent the pending timer from firing and updating state on an unmounted component.
When and how to use it
import { useDebouncedInput } from "rn-markdown-editor";
function SearchBar() {
const { immediateValue, debouncedValue, setInput, clearDebounce } = useDebouncedInput("", 400);
// Fire the API call only when debouncedValue changes
useEffect(() => {
if (!debouncedValue) return;
searchPosts(debouncedValue);
}, [debouncedValue]);
// Clean up any pending timer if the component unmounts mid-type
useEffect(() => () => clearDebounce(), []);
return (
<TextInput
value={immediateValue} // ← always reflects what the user typed instantly
onChangeText={setInput}
/>
);
}
The key rule: bind your input's value to immediateValue, and your side effects to debouncedValue. Never the other way around — binding the input to debouncedValue will make it feel laggy, which defeats the point.
The delay parameter defaults to 300ms, but adjust it based on context:
- Search inputs: 300–500ms is a good range.
- Autosave: 1000–2000ms is more appropriate.
- Instant validation feedback: you may want 0ms debounce on
debouncedValue(or just useimmediateValuedirectly) and only debounce the remote check.
3. useDisclosure — controlled open/closed state with lifecycle callbacks
The problem it solves
Modals, dropdowns, drawers, tooltips, and popovers all share the same pattern: a boolean isOpen flag, plus open, close, and toggle handlers. Writing this from scratch every time with useState is repetitive, but more importantly it's easy to forget to fire side effects consistently — e.g. calling an analytics event on open, or resetting form state on close. useDisclosure centralizes the logic and makes callbacks first-class.
What it does
export const useDisclosure = (props: UseDisclosureProps = {}) => {
const { defaultIsOpen = false, onOpen, onClose, onToggle } = props;
const [isOpen, setIsOpen] = useState(defaultIsOpen);
const open = useCallback(() => {
setIsOpen(true);
onOpen?.();
}, [onOpen]);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
const toggle = useCallback(() => {
setIsOpen((prev) => {
const next = !prev;
if (next) onOpen?.();
else onClose?.();
onToggle?.();
return next;
});
}, [onOpen, onClose, onToggle]);
return { isOpen, onOpen: open, onClose: close, onToggle: toggle };
};
Key decisions:
toggleuses the functional updater form.setIsOpen(prev => !prev)instead ofsetIsOpen(!isOpen)prevents the stale-closure problem. Iftogglewere called twice in the same render cycle, using!isOpendirectly would read a stale value both times and end up toggling once instead of twice. The functional form always reads the most recent state.- Callbacks are
useCallback-stabilized.onOpen,onClose, andonTogglefire inside stable callback references, so child components receiving them as props won't re-render unnecessarily. - All three callbacks are optional. Props use optional chaining (
onOpen?.()) throughout, so you can use the hook purely for state management with no callbacks at all. defaultIsOpensets the initial state, not a controlled value. This is an uncontrolled component pattern — the hook owns theisOpenstate internally after mount. If you need fully controlled behavior, manage the boolean in a parent and pass down the handlers directly instead.
When and how to use it
import { useDisclosure } from "rn-markdown-editor";
function PostOptions() {
const { isOpen, onOpen, onClose, onToggle } = useDisclosure({
onOpen: () => console.log("Options opened"),
onClose: () => resetMenuState(),
});
return (
<>
<Button onPress={onToggle}>Options</Button>
<Modal visible={isOpen} onRequestClose={onClose}>
<OptionsList onDismiss={onClose} />
</Modal>
</>
);
}
You can also open it in a pre-opened state for cases like a settings screen that should always start expanded:
const { isOpen, onClose } = useDisclosure({ defaultIsOpen: true });
One thing to be deliberate about: don't use onToggle for critical state transitions where you need to know which direction the toggle went (open or closed). In those cases use onOpen and onClose explicitly, so the intent is unambiguous.
How the three hooks relate
useMergeState is the base layer — it handles multi-key object state cleanly. useDebouncedInput is built directly on top of it, using merged state to manage immediateValue and debouncedValue as a single co-located unit without one accidentally overwriting the other. useDisclosure is independent but follows the same principle: a focused, stable API with a single job.
Together they cover three of the most common pain points in React Native UI — multi-field form state, search/filter inputs with API calls, and visibility state for overlays.
What's next
We'll continue covering more of the internals in upcoming posts — including a closer look at how the editor's toolbar system is structured, and how custom toolbar items slot in alongside the built-in ones. More to come soon.
System Reliability & Support
The development team remains committed to maintaining a secure, efficient, and highly optimized open-source toolkit. Continuous updates are actively deployed to ensure total cross-platform stability across iOS, Android, and Web deployments.
For technical assistance or to report issues, please submit on the comment or our official NPM Project Page.
Thanks for your time and support. Let's make Steem great, again.
Official NPM Link: https://www.npmjs.com/package/rn-markdown-editor
Primary Architecture: TypeScript / JavaScript
Supported Environments: Expo & Bare React Native CLI
Utility Exports: useDebouncedInput, useDisclosure, useMergeState