Skip to content

Examples / Recipes ​

Demo ​

Explore the demo app to see the library in a real project setup.

Global store with component usage ​

tsx
import { makeObservable, observable, action } from 'mobx';

class UserStore {
  static isGlobal = true;

  public name = 'Matthew';

  constructor() {
    makeObservable(this, {
      name: observable,
      setName: action,
    });
  }

  public setName = (name: string): void => {
    this.name = name;
  };
}

const stores = {
  userStore: UserStore,
};

const User = ({ userStore: { name, setName } }) => {
  return <button onClick={() => setName('John')}>{name}</button>;
};

export default withStores(User, stores);

Relative store for a screen or form ​

Use relative stores by default when state belongs to a component subtree.

ts
import { makeObservable, observable, action } from 'mobx';

class SomeOtherStore {
  public value = '';

  constructor() {
    makeObservable(this, {
      value: observable,
      setValue: action,
    });
  }

  public setValue = (value: string): void => {
    this.value = value;
  };
}

const stores = {
  someOtherStore: SomeOtherStore,
};

Why:

  • lifecycle follows the component subtree
  • componentProps work here
  • onComponentPropsUpdate(props) works here

Screen store with DI, async state, and computed values ​

ts
import type { IConstructorParams, ClassReturnType } from '@lomray/react-mobx-manager';
import { computed, makeObservable, observable } from 'mobx';
import UserStore from './stores/user-store';

class FeatureScreenStore {
  public data = null;
  public isLoading = false;
  public error: string | null = null;

  private readonly userStore: ClassReturnType<typeof UserStore>;
  protected readonly endpoints: IConstructorParams['endpoints'];

  constructor({ getStore, endpoints, componentProps }: IConstructorParams<{ id: string }>) {
    this.userStore = getStore(UserStore)!;
    this.endpoints = endpoints;

    makeObservable(this, {
      data: observable,
      isLoading: observable,
      error: observable,
      isReady: computed,
    });
  }

  public get isReady() {
    return this.data && !this.isLoading;
  }
}

This pattern is useful when a screen needs:

  • injected app services
  • access to another store through getStore(...)
  • local async state
  • derived values kept out of JSX

Connect a screen store to a component ​

ts
import { type StoresType, withStores } from '@lomray/react-mobx-manager';

const stores = {
  featureStore: FeatureScreenStore,
};

type Props = StoresType<typeof stores>;

const FeatureScreen = ({ featureStore: { isReady, load } }: Props) => {
  // UI only reads ready-to-use state
  return null;
};

export default withStores(FeatureScreen, stores);

Reuse a parent store in children ​

Prefer parentStore(Store) over inline config:

ts
import { parentStore } from '@lomray/react-mobx-manager';

const stores = {
  someOtherStore: parentStore(SomeOtherStore),
};

Use this when the child should consume a store created in an ancestor context instead of creating a new one.

Full shape:

tsx
const parentStores = {
  someOtherStore: SomeOtherStore,
};

const childStores = {
  someOtherStore: parentStore(SomeOtherStore),
};

Nested component reads parent store without prop drilling ​

ts
import { parentStore, type StoresType, withStores } from '@lomray/react-mobx-manager';
import FeatureScreenStore from './FeatureScreen.store';

const stores = {
  featureStore: parentStore(FeatureScreenStore),
};

const FeatureCard = ({ featureStore: { data } }: StoresType<typeof stores>) => {
  return null;
};

export default withStores(FeatureCard, stores);

This is a good fit when a nested UI block needs access to feature state but should not receive a long chain of props from parent components.

Global store for app-wide state ​

ts
import { makeObservable, observable } from 'mobx';

class UserStore {
  static isGlobal = true;

  public name = 'Matthew';

  constructor() {
    makeObservable(this, {
      name: observable,
    });
  }
}

Minimal shape:

ts
class UserStore {
  public static isGlobal = true;
  public isAuthProcess = false;
}

Good fit:

  • current user
  • theme
  • app settings

Bad fit:

  • form state
  • screen-specific async state
  • props-derived UI state

Local child store that depends on a parent store and a global store ​

ts
import type {
  IConstructorParams,
  ClassReturnType,
  IRelativeStore,
} from '@lomray/react-mobx-manager';
import { reaction } from 'mobx';
import FeatureScreenStore from './FeatureScreen.store';
import UserStore from './stores/user-store';

class FeatureActionsStore implements IRelativeStore {
  private readonly featureStore: ClassReturnType<typeof FeatureScreenStore>;
  private readonly userStore: ClassReturnType<typeof UserStore>;

  constructor({ getStore }: IConstructorParams) {
    this.featureStore = getStore(FeatureScreenStore)!; // this is a parent store, not a global one
    this.userStore = getStore(UserStore)!;
  }

  public init() {
    const unsubscribe = reaction(
      () => this.featureStore.isRefreshing,
      (isRefreshing) => {
        if (isRefreshing) {
          void this.syncSomething();
        }
      }
    );

    return unsubscribe;
  }

  private async syncSomething() {}
}

This is a good pattern when a local UI block has its own logic but still depends on screen-level and app-level state.

Sync component props into a relative store ​

ts
import { makeObservable, observable } from 'mobx';

class SomeOtherStore {
  public userId = '';

  constructor({ componentProps }: IConstructorParams<{ userId: string }>) {
    this.userId = componentProps.userId;

    makeObservable(this, {
      userId: observable,
    });
  }

  onComponentPropsUpdate(props: { userId: string }) {
    this.userId = props.userId;
  }
}

This is supported only for relative stores.

Persist only truly long-lived data ​

ts
import { Manager } from '@lomray/react-mobx-manager';

class UserStore {
  // profile, settings, preferences, history
}

export default Manager.persistStore(UserStore, 'user');

Use persistence for durable state. Avoid using it for temporary screen state, loading flags, or one-off UI flows.

When a persisted store also has short-lived fields, keep them out of storage: list the persisted fields explicitly, or mark the others as excluded. Changes of fields that are not persisted don't trigger any persist work.

ts
export default Manager.persistStore(FeedStore, 'feed', {
  behaviour: 'include',
  attributes: { local: ['items', 'filters'] },
});

// or inside the store constructor
makeExported(this, { isFetching: 'excluded' });

Persist a store across multiple storages ​

ts
export default Manager.persistStore(StorageStore, 'storage', {
  // storage order matters when behaviour is "exclude"
  attributes: {
    // these props will be saved in cookies
    cookie: ['theme', 'searchParams'],
    // all remaining props will be saved in local storage
    local: ['*'],
  },
  // disable default export of all observable props
  // useful when you want to persist only attributes explicitly routed to storages
  isNotExported: true,
});

What this means:

  • 'storage' is the persisted store id
  • attributes splits the persisted payload by storage id
  • cookie: ['theme', 'searchParams'] sends these fields to the cookie storage
  • local: ['*'] means all remaining exported fields go to the local storage
  • storage order matters because the default behaviour is exclude
  • isNotExported: true turns off the default “export all observable props” behavior for persistence
  • with isNotExported: true, persistence should be treated as explicit and controlled

In the storage layer, CombinedStorage reads attributes and writes each slice of the store state into the matching storage. The first storage acts as the default target when no custom mapping is provided.

Explicit export control with makeExported(...) ​

ts
import { makeExported } from '@lomray/react-mobx-manager';

class DebugStore {
  public filters = {};
  public meta = { version: 1 };
  public internalTimer = null;

  constructor() {
    makeExported(this, {
      filters: 'observable',
      meta: 'simple',
      internalTimer: 'excluded',
    });
  }
}

Use this when the default export behavior is too broad or when persistence must stay explicit and predictable.

Combined storage with cookies, local storage, and SSR init state ​

ts
import Cookie from 'js-cookie';

const initState = getServerState(StateKey.storeManager, IS_PROD);

const storeManager = new MobxManager({
  initState: {
    ...initState,
  },
  logger: {
    level: 4,
  },
  storage: new CombinedStorage({
    cookie: new CookieStorage({
      cookieAttr: { expires: 365 },
      storage: Cookie,
    }),
    local: new MobxLocalStorage(),
  }),
});

What each part does:

  • getServerState(...) restores manager state pushed from SSR or stream rendering into the client
  • initState is merged into the manager so stores can restore their initial request state
  • CombinedStorage(...) lets one manager work with several storages at once
  • cookie and local are storage ids referenced by attributes in persistStore(...)
  • CookieStorage(...) persists JSON under one cookie key
  • cookieAttr: { expires: 365 } configures cookie lifetime
  • MobxLocalStorage() handles browser local storage for the remaining long-lived data

This setup is useful when:

  • some fields must be available through cookies
  • other fields fit better in local storage
  • SSR or stream rendering must hydrate the manager before the app starts

How persisted changes are saved ​

  • the change listener tracks only persisted fields of the store; other stores referenced from its fields are not traversed
  • changes are batched: all persisted stores changed during options.persistDelay (100 ms by default) are serialized once and every storage gets one write
  • a store whose persisted state didn't change is not written again
  • queued changes are written right away when the page is hidden, before a store is destroyed, on manager.destroy() and on manager.flushPersist()
  • on the server changes are saved immediately; persistDelay: 0 does the same in the browser
ts
const manager = new Manager({
  storage,
  options: { persistDelay: 300 },
});

React Native has no page events, so flush when the app goes to background:

ts
import { AppState } from 'react-native';

AppState.addEventListener('change', (state) => {
  if (state !== 'active') {
    void manager.flushPersist();
  }
});

Tests that read a storage right after a store change should wait for the write:

ts
store.setTheme('dark');

await manager.flushPersist();

expect(JSON.parse(localStorage.getItem('stores') ?? '{}')).toMatchObject({
  settings: { theme: 'dark' },
});

or create the test manager with options: { persistDelay: 0 }.

Save every store under its own key ​

LocalStorage, SessionStorage, CookieStorage and AsyncStorage keep all persisted stores in one value, so every save rewrites all of them. With many or large stores use KeyValueStorage: each store is a separate key and a save writes only the changed stores. It also keeps values small, which matters for Android AsyncStorage, where a single large row (around 2 MB) may fail to load.

ts
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Manager } from '@lomray/react-mobx-manager';
import MobxAsyncStorage from '@lomray/react-mobx-manager/storages/async-storage';
import CombinedStorage from '@lomray/react-mobx-manager/storages/combined-storage';
import KeyValueStorage from '@lomray/react-mobx-manager/storages/key-value-storage';

const manager = new Manager({
  storage: new CombinedStorage({
    local: new KeyValueStorage({
      storage: AsyncStorage,
      // stores saved by the previous setup are moved on the first start
      migrateFrom: new MobxAsyncStorage({ storage: AsyncStorage }),
    }),
  }),
});

Any key-value backend with getItem, setItem and removeItem works, sync or async. For example, MMKV:

ts
import { createMMKV } from 'react-native-mmkv';

const mmkv = createMMKV();

const storage = new KeyValueStorage({
  storage: {
    getItem: (key) => mmkv.getString(key),
    setItem: (key, value) => mmkv.set(key, value),
    removeItem: (key) => mmkv.remove(key),
  },
});

What to know:

  • stores are saved as stores:<storeId> plus the stores-keys index; change the prefix with prefix
  • migrateFrom is read only when there is no index yet, i.e. on the first start with the new storage
  • migrated data is kept by default, so an app version rollback still finds it; pass shouldRemoveMigrated: true to remove it right after migration
  • if the migrated stores can't be saved, the app still starts with the old data, and all stores are saved again on the next change
  • flush() removes the stores, the index and the migrateFrom data

IndexedDB storage for the web ​

IndexedDBStorage saves every store as its own record. Writes are asynchronous and don't block the main thread, and the quota is much larger than the ~5 MB of localStorage.

ts
import CombinedStorage from '@lomray/react-mobx-manager/storages/combined-storage';
import IndexedDBStorage from '@lomray/react-mobx-manager/storages/indexed-db-storage';
import LocalStorage from '@lomray/react-mobx-manager/storages/local-storage';

const storage = new CombinedStorage({
  local: new IndexedDBStorage({
    // moved on the first start and used when IndexedDB is not available
    migrateFrom: new LocalStorage(),
  }),
});
  • options: dbName (default 'mobx-manager'), migrateFrom, shouldRemoveMigrated, indexedDB (custom factory, e.g. for tests)
  • state is restored asynchronously on manager.init(): wait for it before rendering persisted stores, e.g. with shouldInit on StoreManagerProvider
  • when IndexedDB can't be opened (private mode restrictions, old browsers), the storage reads and writes migrateFrom

Custom storage with incremental saves ​

A storage only needs get, set and flush. set receives all stores of that storage on every save. Implement the optional saveChanges to write only what changed:

ts
import type { IStorage } from '@lomray/react-mobx-manager';

const storage: IStorage = {
  get: () => readAllStores(),
  set: (value) => writeAllStores(value),
  flush: () => removeAllStores(),
  // called instead of `set` when defined
  saveChanges: ({ changes, value, toJSON }) => {
    // changes: store id => store state JSON, only stores changed in this batch
    // value: all stores of this storage, the same object `set` receives
    // toJSON(): JSON of `value`, assembled from already serialized stores
    changes.forEach((json, storeId) => writeStore(storeId, json));
  },
};

Built-in single-value storages use toJSON(), so unchanged stores are not serialized again. Keep cookie storage for small fields only: browsers ignore cookies larger than ~4 KB, and CookieStorage warns once in the console when the value is bigger.

Suspense query pattern for SSR and stream rendering ​

If your project wraps the internal suspense helper with something like createSuspenseQuery(this), the usage can look like this:

ts
this.suspense = createSuspenseQuery(this);
tsx
suspense.query(() => getData(props), {
  hash: JSON.stringify(props),
});

Why this pattern is useful:

  • it runs a request and throws the promise for React Suspense
  • it stores request completion state on the store
  • it syncs suspense state between server and client
  • it avoids rerunning the same suspense query when the hash is unchanged
  • it can restore and rethrow serialized errors on the client

Under the hood, the internal suspense-query.ts helper also marks its request state field with makeExported(...) so the suspense status can participate in serialization.

Clean up listeners in init ​

ts
import { makeObservable, observable } from 'mobx';

class UserStore {
  public isReady = false;

  constructor() {
    makeObservable(this, {
      isReady: observable,
    });
  }

  init() {
    const unsubscribe = someEmitter.subscribe(() => undefined);

    return () => {
      unsubscribe();
    };
  }
}

That cleanup runs on destroy.

SSR request lifecycle ​

ts
const manager = new Manager();

try {
  await manager.init();
  // render request
} finally {
  manager.destroy();
}

Use one manager per request.

HMR in development ​

ts
import { connectViteHmr } from '@lomray/react-mobx-manager/plugins/dev-extension/hmr';

const manager = new Manager();

if (import.meta.env.DEV) {
  connectViteHmr(manager, import.meta.hot, { appId: 'app' });
}

This restores state by libStoreId. If ids change, restore is skipped.

HMR support is experimental and is still being tested.

Important Tips ​

  • Create global stores only for things like application settings, logged user, theme, and other app-wide state.
  • To get started, stick to the concept: one component subtree owns one relative store subtree.
  • Do not connect the same non-global store to several unrelated components through withStores.
  • Prefer relative stores by default. Reach for global only when the state truly belongs to the whole app.

Best Practices ​

One store per business scope ​

If a screen owns loading, refresh, optimistic updates, error state, and modal refs, that is usually one screen store.

That is much better than:

  • keeping everything in useState and useEffect
  • spreading logic across many hooks
  • passing callbacks and flags through several component layers

Make a store global only when it is truly global ​

If a store must exist in a single shared instance across the app, mark it with static isGlobal = true.

Typical examples:

  • auth
  • user or session
  • app settings
  • localization
  • navigation-level coordination

Do not make a store global just because it feels easier to import.

Parent-child store composition beats prop drilling ​

If a component lives inside a feature scope, do not push data, isLoading, error, refresh, and a long list of callbacks down through props.

Use parentStore(FeatureStore) instead.

This gives you:

  • less prop noise
  • fewer brittle component interfaces
  • easier layout refactors without rewriting contracts

Async state should live in the store ​

Flags like isLoading, isRefreshing, isSubmitting, or isAuthProcess should live next to the async methods that control them.

Strong pattern:

  • makeFetching(this, { getSomeData: 'isLoading' })
  • makeFetching(this, { refreshSomeData: 'isRefreshing' })

This is cleaner than repeating manual try/finally loading control in every screen.

Keep computed values in stores, not in JSX ​

Derived values like:

  • isAuth
  • isFiltersChanged

belong in the store, not inside render logic.

Components should read ready answers instead of recalculating domain logic during render.

Reactions and subscriptions should live near the owning store ​

If one piece of state should trigger another behavior, prefer reaction inside the store.

Examples:

  • filters changed -> refetch the list
  • search history opened -> disable list scroll
  • parent store started refreshing -> child store loaded fresh data

This is usually much clearer than scattering magic useEffect blocks across the component tree.

Persist only long-lived state ​

Manager.persistStore(...) is a good fit for:

  • user
  • localization
  • debug settings

Do not persist temporary screen state, loading flags, or one-off UI flows.

UI refs may live in stores when they are part of the flow ​

Refs such as actionMenuModalRef, plainNavRef, or flashListRef are fine in a store when they are part of the business flow.

This is especially useful in React Native, where refs often participate in navigation, modal control, and gesture-driven flows.

Stores should depend on app abstractions, not on JSX ​

It is normal for a store to know about:

  • apiService
  • AlertService
  • NavigationStore
  • UserStore

It is a bad sign when a store knows about specific JSX structure or layout details.

Released under the MIT License.