Skip to main content

Frontend architecture

Rowy’s frontend architecture is built on the global state manager, Jotai. The UI is built with MUI with our own custom theme.

Building UI

When building new UI, stick to the existing MUI components without setting any variants, like dense, unless if intentional, like setting Button to variant="contained" color="primary" for emphasis. The theme has been intentionally designed to minimize any need to set component variants.

You can find MUI has plenty of documentation with examples and have corresponding API pages.

How do atoms work?

Atoms represent a single piece of state.

The code in src/atoms contain atom config. They define how data is retrieved and what data is stored in each atom. They do not store data.

Creating an atom config here does not immediately cause the atom to be loaded in the app—they need to be called by useAtom (or similar Hook) first.

This then calls the atom’s read function (the first argument in the atom config), which then stores data against the atom config in the nearest matching Provider.

See the Jotai docs for a more comprehensive explanation.

When an atom is called in a component, it’s effectively the same as useState. So when the atom’s value updates, that component re-renders. Be careful of this and be deliberate when using atoms. For example, memoized Table sub-components intentionally do not call atoms in projectScope to reduce the number of re-renders.

This doc on Jotai’s core implementation may help explain this concept.

Atoms reference

Atom configs are organized by scope and function. Comments are provided above the atom config, including examples of how to use them. These are important because of the different types of atoms we have (see below).

info

For an index of available atoms, see rowy-typedoc.vercel.app

Providers and scopes

We use Jotai Providers with different scopes to separate different parts of state throughout the app.

ScopePurpose
projectScopeStores atoms for project config, current user information, Rowy Run, and project-level UI state.
tableScopeStores atoms for a table’s config, rows, functions to update a table, and table-level UI state.

By using scoped Providers, we can provide different states for different component sub-trees.

Example: Sub-Tables are children of Tables

For example, we render a Sub-Table page as a child of the Table page, each with their own Provider. They then render the same Table component, which gets the data from the nearest Provider with tableScope. A simplified version looks like:

<Provider scope={tableScope}>
<Table /> {/* Root table */}
<Provider scope={tableScope}>
<Table /> {/* Sub-table */}
</Provider>
</Provider>

Diagram explaining the concept

The two Table components rendered have independent states* because they interact with different Providers, despite using the same tableScope.

*Except atoms that store their value in the URL hash

These atoms (e.g. modal open states) cannot be independent, so in this case, when the sub-table is open, we disable modals in the root table.

In the future, we could make a modification to atomWithHash that prepends the scope or an ID to the name in the URL, so we can have #rootTable-modal= and #subTable-modal= in the same URL. But currently, only one modal appears at a time, so this isn’t necessary.

Sources

The standard Jotai pattern is to define how to get data in the atom config itself. We don’t do this to allow for multiple sources of data, so we can expand from Firestore and Firebase in the future. Many atoms in projectScope and tableScope only define what data they store.

Data is retrieved and stored in atoms using Source components, which have effects that run when they’re mounted. These Source components must be rendered inside a Provider.

Diagram explaining the concept

  • App renders ProjectSourceFirebase to populate the projectScope atoms.
  • ProvidedTablePage and ProvidedSubTablePage render TableSourceFirestore to populate the tableScope atoms. This allows us to have a TablePage component that displays the Table UI, and can expect to have all atoms in tableScope available, regardless of the source of data.

Types of atoms

To implement multiple sources, we have different “types” of atoms, which are consumed in different ways. It’s best to check the atom config’s comment to find out which type it is. As such, it’s important to keep these comments up to date.

Read-only atoms

Store a value in the atom from a Source. You cannot modify the value of these atoms directly.

const [currentUser] = useAtom(currentUserAtom, projectScope);

Read-write atoms

Store a value in the atom, and provide a way to update that value. These are typically used for UI state, including atomWithHash, which stores the value in the URL.

Component that uses the value:

const [state, setState] = useAtom(confirmDialogAtom, projectScope);

Component that can set the value, but is not dependent on the value:

const confirm = useSetAtom(confirmDialogAtom, projectScope);
confirm({ handleConfirm: () => ... });

Function atoms

Store a function that can be called in the atom’s value. Calling this function will run code that will update another atom’s value.

These atoms are necessary to provide a way to update data that come from Sources. For example, updateProjectSettingsAtom could contain a function to update the project settings for a non-Firebase project in the future.

const [updateProjectSettings] = useAtom(updateProjectSettingsAtom, projectScope);
if (updateProjectSettings) {
updateProjectSettings({ ... });
}

Write-only atoms

Do not store a value. Calling the write function will run code that will update another atom’s value.

These atoms are like simpler versions of function atoms, and can have automated testing, since they are independent of Sources.

These are dependent on a more generic function atom to update the value in the Source, but can call the same code regardless of the Source.

For example, addRowAtom does the same tasks (e.g. check for missing values), then calls _updateRowDbAtom to write the new row to the database. TableSourceFirestore provides the function stored in _updateRowDbAtom.

const addRow = useSetAtom(addRowAtom, tableScope);
addRow({ row: [ {...}, ... ] });

Suspense

Jotai has built-in support for React Suspense. This means we don’t need to check if an atom’s value is still loading in many places.

Built-in Suspense support

If an atom config’s read function has an async request or call, the nearest component will suspend while that Promise is not resolved.

Any component that uses that atom (useAtom) can expect the atom’s value to always be there, instead of an undefined state to denote loading.

Simulated Suspense behavior

But to support multiple sources for projectScope and tableScope atoms, we cannot rely on Jotai’s Suspense support, since we don’t specify how to get data in the atom config’s read function.

So we simulate this behavior by initially setting the atom’s value to be a Promise that never resolves, then set the value correctly when the data has been loaded in.

Example: currentUserAtom

For example, we set the value of currentUserAtom as a Promise while we wait for Firebase to authenticate.

(setCurrentUser as any)(new Promise(() => {}));

firebaseAuth.onAuthStateChanged(async (user) => {
setCurrentUser(user);
...
}

Source code ↗

However, this means the very first render of the component will have currentUserAtom’s value be undefined (loading) instead of null (signed out), so we still need to check the value in App.

{
currentUser === undefined ? (
<Loading fullScreen message="Authenticating" />
) : (
<Routes>...</Routes>
);
}

Folder structure

FolderPurpose
assetsImages, icons, and fonts.
atomsAtom configs.
componentsComponents used inside pages and general components used throughout the app.
components/fieldsAll code used to implement field types, including Table cell and Side Drawer field code.
configConstants that can be used to meaningfully configure the app, especially for open source users.
constantsConstants used throughout the app that shouldn’t be configurable.
contextsAny React Contexts used in the app. Avoid adding to this and use Jotai atoms instead, unless if a feature or 3rd-party package needs it, like snackbars.
hooksGeneral-purposes Hooks that can be used throughout the app. For example, Firestore collection and document listeners are Hooks stored here.
layoutsImplements layouts used in pages. For example, the side navigation drawer and auth layout.
pagesPage components rendered by React Router routes. Generally, these use Jotai atoms and pass data into components. These also render Sources.
sourcesComponents that run effects and other code, which inject data into Jotai atoms. For example, ProjectSourceFirebase injects data into projectScope using Firebase as a source.
testGeneral test scripts, such as rendering the whole app. Test scripts can also live alongside the code they’re testing, like src/atoms/tableScope/columnActions.test.ts
themeCode to implement Rowy’s MUI theme. Modify these files to apply global style changes.
typesTypes used throughout the app, such as ColumnConfig.
utilsSmall utility functions used to standardize behavior across the app. For example, rowyUser creates the same object to store the current user’s data in a table.