Data Storage

SmartCommon offers several solutions for client-side data storage:

  • useGlobalStates: global state with automatic persistence (localStorage/sessionStorage)
  • useStates: reactive local state with path notation
  • useDb: IndexedDB database via Dexie
  • useCachedQuery: data cache with strategies (NetworkFirst, CacheFirst, SWR)

For complete offline synchronization, see Offline Synchronization (useSyncClient).

Documentation IndexedDB Documentation Dexie Documentation Redux Toolkit

The useGlobalStates hook provides a global state with automatic persistence. This is the recommended method for user data (session, preferences, etc.).

Basic Usage

import { useGlobalStates } from '@cap-rel/smartcommon';

export const MyComponent = () => {
  const gst = useGlobalStates();

  // Read a value
  const user = gst.get('user');

  // Write to localStorage (persistent)
  const login = (userData) => {
    gst.local.set('user', userData);
  };

  // Write to sessionStorage (session only)
  const setTempData = (data) => {
    gst.session.set('tempData', data);
  };

  // Delete a value
  const logout = () => {
    gst.unset('user');
  };

  return (
    <div>
      {user ? `Hello ${user.name}` : 'Not connected'}
    </div>
  );
};

Available Methods

Method Description
gst.get(path) Retrieve a value by its path
gst.local.set(path, value) Store in localStorage (persistent)
gst.session.set(path, value) Store in sessionStorage (session)
gst.unset(path) Remove a value
gst.values Object containing all values

Path Notation

You can access nested data using dot notation:

// Set nested data
gst.local.set('user.preferences.theme', 'dark');
gst.local.set('user.preferences.language', 'en');

// Read nested data
const theme = gst.get('user.preferences.theme'); // 'dark'
const prefs = gst.get('user.preferences'); // { theme: 'dark', language: 'en' }

useStates (local state)

The useStates hook provides a reactive local state with the same API as useGlobalStates.

import { useStates } from '@cap-rel/smartcommon';

export const MyForm = () => {
  const st = useStates({
    initialStates: {
      name: '',
      email: '',
      errors: {}
    },
    debug: true // displays changes in the console
  });

  const handleChange = (field, value) => {
    st.set(field, value);
  };

  const validate = () => {
    if (!st.get('name')) {
      st.set('errors.name', 'Name is required');
    }
  };

  return (
    <form>
      <Input
        value={st.get('name')}
        onChange={(e) => handleChange('name', e.target.value)}
        error={st.get('errors.name')}
      />
    </form>
  );
};

Available Methods

Method Description
st.get(path) Retrieve a value
st.set(path, value) Set a value
st.unset(path) Remove a value
st.values Object containing all values
st.states Alias of values

Array Manipulation

const st = useStates({ initialStates: { items: [] } });

// Add an element (push)
st.set('items[]', { id: 1, name: 'Item 1' });

// Modify an element by index
st.set('items[0].name', 'Modified item');

// Remove an element by index
st.unset('items[0]');

useCachedQuery (cache with strategies)

The useCachedQuery hook allows caching API data in IndexedDB with configurable strategies.

import { useCachedQuery } from '@cap-rel/smartcommon';

const ItemsList = () => {
  const db = useMemo(() => new Dexie('myApp'), []);

  const {
    data,          // data (from cache or network)
    isLoading,     // loading in progress
    isFromCache,   // true if data comes from cache
    isStale,       // true if data is stale
    error,         // possible error
    lastFetch,     // timestamp of last fetch
    refetch,       // force a new fetch
    invalidate,    // invalidate cache
  } = useCachedQuery({
    db,
    store: 'cache',         // IndexedDB store name (schema: 'key')
    key: 'items-list',      // unique cache key
    fetchFn: () => api.get('items'),  // fetch function
    strategy: 'networkFirst',         // cache strategy
    ttl: 3600000,           // cache TTL (1h default)
    staleTime: 60000,       // time before cache is "stale" (1min default)
    enabled: true,          // enable/disable fetch
  });

  if (isLoading) return <Spinner />;

  return (
    <List>
      {data?.map(item => (
        <ListItem key={item.id}>{item.label}</ListItem>
      ))}
    </List>
  );
};

Available Strategies

Strategy Constant Behavior
networkFirst NETWORK_FIRST Network first, cache as fallback (default)
cacheFirst CACHE_FIRST Cache first, network if absent or expired
swr STALE_WHILE_REVALIDATE Returns cache immediately, revalidates in background
import { CACHE_STRATEGIES } from '@cap-rel/smartcommon';

// Usage with constant
useCachedQuery({
  // ...
  strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
});

IndexedDB database (Dexie)

To store large amounts of data or complex structured data, SmartCommon provides the Db class (and the useDb hook), based on Dexie.

One database, defined in src/db/

An application has a single local database, declared once in src/db/index.js and imported wherever it is needed: the business hooks useDb<Feature>, useReferenceSync, useCachedQuery... See Architecture for the full layout.

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
  name: 'myApp',
  version: 1,
  stores: {
    items: '++id, name, category, createdAt',
    categories: '++id, name',
    // useReferenceSync metadata (key / value)
    syncMeta: 'key'
  }
}).db;

Components do not open a database: they import db and go through a business hook.

// src/db/stores/items/useDbItems.jsx
import { db } from 'src/db';

export const useDbItems = () => ({
  list: () => db.items.toArray(),
  add: (item) => db.items.add(item),
  update: (id, changes) => db.items.update(id, changes),
  remove: (id) => db.items.delete(id),
  byCategory: (category) => db.items.where('category').equals(category).toArray()
});
import { useEffect, useState } from 'react';
import { useDbItems } from 'src/db';

export const ItemsManager = () => {
  const dbItems = useDbItems();
  const [items, setItems] = useState([]);

  useEffect(() => {
    dbItems.list().then(setItems);
  }, []);

  return (/* ... */);
};

The useDb hook

useDb({ name, version, stores }) builds the same database from a component (instance memoized as long as the schema does not change). It suits a prototype or an isolated component. As soon as two parts of the application share data, use the src/db/ database: two useDb calls with different schemas on the same name open two competing versions of the same database. useDb only handles one schema version, see Schema migrations.

Schema migrations

useDb and the new Db({ version, stores }) form only describe one schema version. As soon as a released version of the application has created the database on users' devices, a schema change goes through a new version: never modify an existing version, or Dexie refuses to open the database (VersionError) on devices already installed.

To chain several versions, pass versions to Db in src/db/index.js. Each entry gives the full schema of that version and, if needed, an upgrade function that transforms the existing data:

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
  name: 'myApp',
  versions: [
    {
      version: 1,
      stores: {
        items: '++id, name, category',
        drafts: '++id'
      }
    },
    {
      // v2: index on status, "name" renamed to "label", drafts removed
      version: 2,
      stores: {
        items: '++id, label, category, status',
        drafts: null
      },
      upgrade: (tx) => tx.items.toCollection().modify((item) => {
        item.label = item.name;
        delete item.name;
        item.status = item.status ?? 'active';
      })
    }
  ]
}).db;

Rules:

  • Version numbers are strictly increasing. A device still on v1 goes through every step up to the last one, upgrade included.
  • Repeat the full schema in each version: createdAt and updatedAt are only added automatically on the stores listed in the last one.
  • A store set to null is deleted with its data.
  • upgrade receives the Dexie transaction of the migration: make no network call in it, it ends as soon as it waits for anything other than IndexedDB.
  • versions replaces version and stores: do not mix both forms.

Automatic Features

Db and useDb automatically add:

  • createdAt: creation timestamp
  • updatedAt: last modification timestamp
  • logs table: journal of all operations (create, update, delete)

Dexie Queries

// All items
const all = await db.items.toArray();

// By primary key
const item = await db.items.get(1);

// Filter
const filtered = await db.items
  .where('category')
  .equals('electronics')
  .toArray();

// Sort
const sorted = await db.items
  .orderBy('createdAt')
  .reverse()
  .toArray();

// Limit
const first10 = await db.items.limit(10).toArray();

// Count
const count = await db.items.count();

Tip

For API cache with automatic strategies, prefer useCachedQuery. For complete offline synchronization with conflict management, use useSyncClient.

Redux (classic method)

If you prefer to use Redux directly, SmartCommon provides ReduxProvider.

Create a Slice

// src/redux/reducers/sessionSlice.js

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  data: JSON.parse(sessionStorage.getItem("session")) ?? null
};

const sessionSlice = createSlice({
  name: "session",
  initialState,
  reducers: {
    setSession(state, action) {
      state.data = action.payload;
      sessionStorage.setItem("session", JSON.stringify(action.payload));
    },
    unsetSession(state) {
      state.data = null;
      sessionStorage.removeItem("session");
    }
  },
});

export default sessionSlice.reducer;
export const { setSession, unsetSession } = sessionSlice.actions;

Configure the Store

// src/redux/index.js

import sessionReducer from "./reducers/sessionSlice";
import { combineReducers } from "redux";
import { configureStore } from "@reduxjs/toolkit";

const rootReducer = combineReducers({
  session: sessionReducer
});

export const reduxStore = configureStore({ reducer: rootReducer });
export * from "./reducers/sessionSlice";

Use in Components

import { useSelector, useDispatch } from "react-redux";
import { setSession, unsetSession } from "../../../redux";

export const MyComponent = () => {
  const session = useSelector(state => state.session.data);
  const dispatch = useDispatch();

  const login = (data) => dispatch(setSession(data));
  const logout = () => dispatch(unsetSession());

  return (/* ... */);
};

Method Comparison

Method Use Case Persistence
useGlobalStates User session, preferences localStorage / sessionStorage
useStates Form state, local UI Memory (non-persistent)
useDb Structured data, local storage IndexedDB
useCachedQuery API cache with strategies IndexedDB
useSyncClient Complete offline synchronization IndexedDB
Redux Complex applications, middleware Configurable

Tip

For most cases, useGlobalStates and useStates are sufficient. For IndexedDB storage, declare the application database in src/db/ with Db. For API data caching, use useCachedQuery. For complete offline mode with synchronization, use useSyncClient.

See Also