Architecture
This page describes the file organization of a SmartMaker application. Unlike a regular React project, file location has technical consequences: it determines the ability to extract a feature to another application, and a poor database layer split causes a loading error that is difficult to diagnose.
Overview
mobile/
├── public/
│ ├── images/ # PWA icons and static images
│ └── locales/
│ └── <lang>/
│ └── <feature>.json # one file per language AND per feature
├── src/
│ ├── api/
│ │ ├── index.js
│ │ └── mapping/
│ │ └── <feature>.js # backend <-> front mapping, one per feature
│ ├── db/
│ │ ├── index.js # Db instantiation
│ │ └── stores/
│ │ └── <feature>/
│ │ ├── indexes.js # Dexie schema, NO imports
│ │ ├── useDb<Feature>.jsx # business CRUD hook
│ │ └── index.js # barrel
│ ├── components/
│ │ ├── app/ # Router, Provider, Head, Toaster
│ │ ├── layouts/ # shared layouts
│ │ ├── global/ # cross-cutting application components
│ │ └── pages/
│ │ ├── public/<Page>/ # unauthenticated pages
│ │ ├── private/<Page>/ # authenticated pages
│ │ └── errors/<Page>/
│ ├── global-state/
│ │ └── slices/ # UI state only
│ ├── hooks/ # cross-cutting application hooks
│ ├── i18n/
│ │ └── index.js
│ ├── utils/
│ │ ├── constants/
│ │ ├── functions/
│ │ └── maps/
│ │ ├── form.jsx # field type -> input component
│ │ └── list.jsx # column type -> cell component
│ ├── assets/ # files handled by compilation
│ ├── appConfig.js
│ ├── main.jsx # entry point
│ └── sw.js # Service Worker (injectManifest mode)
├── .env # environment variables, not versioned
├── .env.example
├── index.html
├── eslint.config.js
├── package.json
└── vite.config.js
An application generated by SmartBoot comes with this tree structure already in place, with an example for each location: a complete users store (indexes.js and useDbUsers), a commented api/mapping/, the maps form.jsx and list.jsx, and five translation namespaces. You fill it, you don't have to create it.
The Guiding Principle: One Feature, One Folder
A business feature (interventions, tasks, quotes, inventory) must be able to be copied as-is to another SmartMaker application. In concrete terms, it is distributed across three locations and three only:
| Location | Content |
|---|---|
db/stores/<feature>/ |
Dexie schema and CRUD hook |
api/mapping/<feature>.js |
conversion between backend and front format |
locales/<lang>/<feature>.json |
translations for the feature namespace |
The test is simple: copy these three elements to another project, add the route, and it should work without any other modification. If an import "leaks" to a file specific to the original application, it's a flaw to be corrected.
The Data Layer
All CRUD in One Hook
Each feature exposes a useDb<Feature> hook that carries all local database accesses.
// src/db/stores/interventions/useDbInterventions.jsx
import { db } from "src/db";
export const useDbInterventions = () => {
const list = async (filters) => { /* ... */ };
const get = async (id) => { /* ... */ };
const create = async (payload) => { /* ... */ };
const update = async (id, payload) => { /* ... */ };
const remove = async (id) => { /* ... */ };
return { list, get, create, update, remove };
};
The hook only knows about the Db class, useGlobalStates and useApi. Nothing else from the host project.
Warning
Never put Dexie calls directly in a page component. This is the most common anti-pattern, and it makes a feature impossible to extract later.
The hook name follows the form useDb<Feature>: useDbInterventions, useDbTasks, useDbProducts. Forms like use<Feature>Services found in older projects are legacy to be renamed.
The indexes.js File, and Why It's Mandatory
Important
The Dexie schema for each feature must live in an indexes.js file with no imports, and db/index.js must import it directly, without going through the barrel.
Without this separation, the import graph forms a cycle:
db/index.js
-> import { tasksIndexes } from "./stores" (barrel)
-> stores/tasks/index.js
export const tasksIndexes = "..."
export * from "./useDbTasks" <- pulls the hook
-> useDbTasks.jsx
import { db } from "src/db" <- back to start
At compile time, this cycle is partially unwound by Vite and produces a Cannot access 'tasksIndexes' before initialization error when loading the bundle. The symptom is cruel: npm run build succeeds, because it doesn't load the bundle, and the application crashes on startup with an error that seems to come from elsewhere (broken authentication, login loop).
The solution, which breaks the cycle by making indexes.js a leaf in the graph:
// src/db/stores/tasks/indexes.js -- ZERO import
export const tasksIndexes = "++id, ref, status, updatedAt";
// src/db/index.js -- imports LEAFS, not the barrel
import { tasksIndexes } from "./stores/tasks/indexes";
import { projectsIndexes } from "./stores/projects/indexes";
export const db = new Db({
name: "myapp",
version: 1,
stores: { tasks: tasksIndexes, projects: projectsIndexes },
}).db;
export * from "./stores"; // acceptable here: db is already built
The barrel stores/<feature>/index.js remains normally usable by pages and other hooks. Only db/index.js must shortcut.
Where Data Goes
| Nature | Location |
|---|---|
| persisted business data (interventions, quotes, photos) | Db (Dexie), via useDb<Feature> |
| persistent UI state (theme, language, filters, last page) | useGlobalStates (Redux and redux-persist) |
| ephemeral UI state (form in progress, open modal) | useState or useStates local |
Warning
Never place a list of business objects in a Redux slice. It must live in Dexie, and components read it via the feature's hook.
Organizing Components
This is the most frequently asked question. The classification rule is one sentence: we classify a component according to its scope, not according to its subject.
| Folder | What to put there | Membership test |
|---|---|---|
components/app/ |
application plumbing: Router, provider composition, Head, Toaster |
there is only one instance in the application |
components/layouts/ |
layouts shared by multiple pages | it wraps pages, it is not one |
components/global/ |
cross-cutting components reused by multiple pages | it is imported by at least two pages |
components/pages/<visibility>/<Page>/ |
a page, mounted on a route | it corresponds to a Router entry |
Pages are then organized by visibility: public/ for what is accessible without authentication, private/ for the rest, errors/ for error pages. This split is not decorative: it reflects what the RouteGuard protects and immediately makes visible which page is exposed.
One Component, One Folder
Each component has its own folder with an index.jsx, even if it's alone. This allows later adding, in the same place, its styles, sub-components and tests, without moving or correcting a single import.
components/pages/private/InterventionPage/
├── index.jsx # the page
├── Header/
│ └── index.jsx # sub-component specific to this page
└── LinesTable/
└── index.jsx
Tip
A sub-component used by only one page remains in that page's folder. It only moves to components/global/ when a second page uses it. Moving in anticipation clutters the common space with components that are not shared by anyone.
A Page Should Know Nothing About the Application
A business page must be able to be mounted in the router of another application without modification.
To do:
- receive its dependencies through SmartCommon hooks (
useNavigation,useApi,useGlobalStates) or through its props - pass form components through the
utils/maps/form.jsxmap, so that the host application can override them by field type - expose navigation paths in a function, rather than hardcoding
navigate('/interventions/123')
Not to do:
- import
appConfigfrom a business page - read a Redux slice specific to the host application
Translations: One Namespace per Feature
public/locales/
├── fr/
│ ├── common.json
│ ├── interventions.json
│ └── products.json
└── en/
├── common.json
├── interventions.json
└── products.json
const { t } = useTranslation('interventions');
All namespaces must be declared in the i18next configuration to be preloaded: this is essential for offline operation, as the Service Worker embeds them via its **/*.json pattern.
Warning
Without Suspense, add react: { useSuspense: false } to the i18next configuration. Otherwise, a page reached by direct navigation before the end of loading its namespace remains frozen on raw keys.
The keyPrefix should never be hardcoded in a reusable hook: it is received as a parameter, with a default value.
// To avoid
const useIntStatuses = () => useTranslation(undefined, { keyPrefix: 'intStatuses' });
// Preferred
const useIntStatuses = (keyPrefix = 'intStatuses') => useTranslation(undefined, { keyPrefix });
Configuration: No Hardcoded Values
Everything that changes from one project or environment to another goes through a Vite environment variable or a provider prop.
| Variable | Role |
|---|---|
VITE_API_URL |
Backend URL; forced relative in production build |
VITE_APP_NAME |
application name |
VITE_APP_VERSION |
version and build number, injected by Makefile |
VITE_LOCALES |
list of languages, derived from subdirectories of public/locales/ |
appConfig.js can centralize these values, but should only be imported from the application shell, never from a business feature.
Files at the Root of mobile/
| File | Role |
|---|---|
.env |
environment variables, ignored by Git |
.env.example |
template for .env, versioned |
index.html |
page where React mounts the application; carries the link to the manifest |
vite.config.js |
Vite configuration, including PWA |
eslint.config.js |
lint configuration |
package.json |
dependencies and scripts |
Review Checklist
To be completed before opening a merge request, or when taking over an existing project:
- [ ] all business CRUD is in
useDb<Feature>, no Dexie calls in a page - [ ] each
db/stores/<feature>/has itsindexes.jswithout imports, anddb/index.jsimports the leaves - [ ] the backend/front mapping is isolated in
api/mapping/<feature>.js - [ ] no business data in a Redux slice
- [ ] no business page imports
appConfigor a slice specific to the application - [ ] one i18n namespace per feature, all declared in the configuration
- [ ] no
fetchoraxios: everything goes throughuseApi - [ ] no hardcoded URLs or ports
- [ ] one component, one folder; sub-components stay with their page as long as they only serve it
See Also
- Components and Pages
- Data Storage
- Offline Synchronization
- Translations
- PWA - Service Worker and offline startup
- Training - Best Practices