skip to content

How do react-native-mmkv's useMMKVString and sibling hooks keep a React Native component in sync when a stored value changes?

level: middleimportance: should knowfreq 34%

answer

  1. useSyncExternalStore underneath
  2. subscribe via addOnValueChangedListener
  3. filtered to the hook's key
  4. setter with undefined removes the key
  5. other processes: checked on app active

basics

~20 s

The hooks subscribe to the instance's value-changed listener through useSyncExternalStore and re-read their key synchronously whenever that key changes. Any write to the same instance id in the app, from a hook or plain code, re-renders every component using that key.

solid answer

~40 s

`useMMKVString(key, instance?)`, `useMMKVNumber`, `useMMKVBoolean`, `useMMKVBuffer` and `useMMKVObject` return a `[value, setValue]` pair. Under the hood they use React's `useSyncExternalStore`: the subscribe function registers `addOnValueChangedListener` on the instance and only notifies React when the changed key matches, and the snapshot is a synchronous getter call, so the first render already has the value. Because the native listener registry is keyed by instance id, a `storage.set` from a non-React module, another screen or another hook re-renders every subscriber. The setter accepts a value or an updater; passing `undefined` removes the key. `useMMKVObject` wraps the string hook with `JSON.parse`/`JSON.stringify`. Outside components I use `addOnValueChangedListener` directly and call `remove()` on the returned listener when done.

go deeper

for a junior

Recall the typed hooks, that they return a value and a setter like useState, and that setting undefined removes the key.

for a middle

Explain the useSyncExternalStore mechanism: a key-filtered value-changed listener for subscription and a synchronous getter for the snapshot.

for a senior

Reason about who triggers re-renders (any write to the same id, clearAll for every key), listener cleanup, and the cost of large objects behind useMMKVObject.

for a principal

Decide when MMKV hooks can serve as lightweight shared state and when a dedicated state library should own the data, with MMKV only persisting it.

## The hook family react-native-mmkv ships React hooks for function components: | Hook | Returns | Notes | |---|---|---| | `useMMKVString(key, instance?)` | `[string \| undefined, setter]` | | | `useMMKVNumber(key, instance?)` | `[number \| undefined, setter]` | | | `useMMKVBoolean(key, instance?)` | `[boolean \| undefined, setter]` | | | `useMMKVBuffer(key, instance?)` | `[ArrayBuffer \| undefined, setter]` | | | `useMMKVObject<T>(key, instance?)` | `[T \| undefined, setter]` | JSON over the string hook | | `useMMKV(config?)` | an instance | default or configured instance | | `useMMKVListener(fn, instance?)` | nothing | called with each changed key | | `useMMKVKeys(instance?)` | `string[]` | updates as keys are added or removed | Without the `instance` argument the hooks use a shared default instance. ## How a hook stays in sync The typed hooks are built by one factory around React's **`useSyncExternalStore`**, the hook React provides for subscribing to stores that live outside React: 1. **Subscribe.** The hook calls `mmkv.addOnValueChangedListener(changedKey => ...)` and only tells React to re-render when `changedKey === key`. The returned listener's `remove()` runs on unmount or when the key or instance changes. 2. **Snapshot.** React reads the current value with a synchronous getter such as `mmkv.getString(key)`. Because this read is synchronous, the value is present in the first render, with no loading state. 3. **Set.** The setter accepts a value or an updater function. Strings, numbers, booleans and `ArrayBuffer`s are written with `set`; **`undefined` calls `remove(key)`**; any other object throws, which is why objects go through `useMMKVObject`. ```tsx import { Switch } from "react-native"; import { useMMKVBoolean } from "react-native-mmkv"; export function ShowCompletedToggle() { const [showCompleted = false, setShowCompleted] = useMMKVBoolean("showCompleted"); return <Switch value={showCompleted} onValueChange={setShowCompleted} />; } ``` ## Who triggers a re-render In native code the listener registry is **keyed by the instance id**, and `set`, `remove` and `clearAll` notify it. Consequences: - A write from a plain TypeScript module (a sync service, a sign-out routine) re-renders every mounted component that uses that key. - Two separate `createMMKV({ id: "x" })` objects in the same app share notifications, because they share the id. - `remove` notifies only when a key was actually removed. - `clearAll()` notifies once for every key that existed. Changes made by **another process**, such as an app extension writing to a shared app-group instance, are not pushed through this registry. The library calls `checkContentChanged()` on each instance when the app returns to the `active` state, which reloads data changed outside the process; instances meant for that should use `mode: "multi-process"`. ## Using listeners outside components ```typescript const listener = storage.addOnValueChangedListener((key) => { if (key === "todos") syncBadgeCount(); }); // later, for example on sign-out listener.remove(); ``` A listener that is never removed keeps its callback, and whatever the callback closes over, alive. `useMMKVListener` handles removal for you inside components and always calls the latest callback. ## Watching keys rather than values Two hooks observe the instance rather than one key: - **`useMMKVListener(fn, instance?)`** registers a listener for the component's lifetime and always invokes the latest `fn`, so the callback does not need to be memoised. It suits side effects such as updating a badge when `todos` changes. - **`useMMKVKeys(instance?)`** returns the list of keys and re-fetches it only when a change adds or removes a key; overwriting an existing key does not trigger a re-fetch. It suits screens that list per-item keys, such as `todo-<id>` entries. Both follow the same rule as the typed hooks: they see every in-process write to the instance id, whoever made it. ## Pitfalls - **Objects re-parse on every change.** `useMMKVObject` stores JSON, so each write to that key parses the whole document again and yields a new object identity. A large to-do list stored under one key re-parses on every toggle. - **Corrupt JSON throws during render.** `useMMKVObject` calls `JSON.parse` without a guard, so a bad stored value surfaces as a render error. - **Writing during render** creates a loop: the write notifies, the component re-renders, and writes again. Write in event handlers or effects. - **Missing values are `undefined`.** Supply a default with destructuring, as above, rather than assuming a stored value exists.

  • Why does a component using useMMKVString not need a loading state?
    The hook's snapshot is a synchronous `getString` call, so React receives the stored value during the first render. With Async Storage the value arrives only after a Promise resolves, which forces an initial placeholder render.
  • An app extension writes to a shared MMKV instance. When does the main app see it?
    Not through the in-process listener registry, which only covers writes made inside the app process. The library calls `checkContentChanged()` when the app becomes active, reloading data changed outside the process; such instances should be created with `mode: 'multi-process'`.

saying these in an interview costs you the question

  • Believing MMKV hooks only see writes made through the same hook
  • Expecting setValue(undefined) to store the string 'undefined'
  • Passing a plain object to useMMKVString's setter
  • Forgetting to call remove() on a manually added listener
  • Writing to a hook's key during render