Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use jotai to store global preferences #1823

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions docs/adr/adr-global-state-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# ADR: Use Jotai for Global State Management

Author: [Felipe](https://github.com/fhenrich33)

Trello-card: https://trello.com/c/CK6TtXPS/1495-20-fe-replace-globalpreferencescontext

## Status

Proposed, Implementing

## Context or Problem Statement

We need a global state management solution for our React application. Currently, we are using React Context for this purpose. However, we have encountered some issues with ease of use and rendering performance. We are considering switching to a more efficient state management library and only leaving React Context for mandatory Dependency Injection.

## Decision Drivers

1. **Ease of development:** How simple is it to implement and maintain the state management solution?
2. **Rendering performance:** How well does the solution minimize unnecessary re-renders?
3. **Scalability:** How well does the solution handle increasing complexity and state size?
4. **Community and support:** How active and supportive is the community around the solution?

## Considered Options

1. Continue using React Context
2. Switch to Jotai
3. Switch to Zustand
4. Switch to Legend State

## Consequences

### Advantages of Jotai

1. **Ease of development:** Jotai provides a simpler and more intuitive API for managing state compared to React Context. It allows for more granular control over state updates, making it easier to manage complex state logic.
2. **Rendering performance:** Jotai minimizes unnecessary re-renders by only updating components that depend on the specific piece of state that has changed. This leads to better performance compared to React Context, which can cause entire component trees to re-render when the context value changes.
3. **Scalability:** Jotai's atom-based approach allows for better scalability as the state grows in size and complexity. Each atom represents a single piece of state, making it easier to manage and reason about the state.
4. **Community and support:** Jotai has an active and growing community, with good documentation and support. It is also backed by the same team that maintains Zustand, another popular state management library.

#### Jotai Example

```javascript
import { atom, useAtom } from "jotai";

const countAtom = atom(0);

function Counter() {
const [count, setCount] = useAtom(countAtom);
return (
<div>
<button onClick={() => setCount((c) => c - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount((c) => c + 1)}>+</button>
</div>
);
}
```

### Drawbacks of Jotai

1. **Learning curve:** While Jotai is simpler to use than React Context, it still requires developers to learn a new API and mental model for managing state.
2. **Ecosystem:** React Context is a built-in feature of React, while Jotai is an external library. This means that Jotai may not have the same level of ecosystem integration and support as React Context.

### Advantages of Zustand

1. **Ease of development:** Zustand provides a simple and flexible API for managing state, with minimal boilerplate.
2. **Rendering performance:** Zustand minimizes unnecessary re-renders by using a subscription-based model, ensuring that only the components that depend on the changed state are updated.
3. **Scalability:** Zustand's flexible API allows for easy management of complex state logic and large state trees.
4. **Community and support:** Zustand has a growing community and is maintained by the same team as Jotai, ensuring good support and documentation.

#### Zustand Example

```javascript
import create from "zustand";

const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));

function Counter() {
const { count, increment, decrement } = useStore();
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
</div>
);
}
```

### Drawbacks of Zustand

1. **Learning curve:** Zustand requires developers to learn a new API and mental model for managing state.
2. **Ecosystem:** Zustand, like Jotai, is an external library and may not have the same level of ecosystem integration as React Context.

### Advantages of Legend State

1. **Ease of development:** Legend State provides a declarative API for managing state, making it easy to understand and use.
2. **Rendering performance:** Legend State optimizes rendering performance by using a fine-grained reactivity system, ensuring that only the necessary components are updated.
3. **Scalability:** Legend State's declarative approach allows for easy management of complex state logic and large state trees.
4. **Community and support:** Legend State has a dedicated community and good documentation, providing ample support for developers.

#### Legend State Example

```javascript
import { observable } from "@legendapp/state";
import { observer } from "@legendapp/state/react";

const count = observable(0);

const Counter = observer(() => (
<div>
<button onClick={() => count.set(count.get() - 1)}>-</button>
<span>{count.get()}</span>
<button onClick={() => count.set(count.get() + 1)}>+</button>
</div>
));
```

### Drawbacks of Legend State

1. **Learning curve:** Legend State requires developers to learn a new API and mental model for managing state.
2. **Ecosystem:** Legend State is an external library and may not have the same level of ecosystem integration as React Context.

## Comparison with React Context

### Ease of development

- **React Context:** Requires creating context providers and consumers, which can lead to boilerplate code and complexity in managing state updates. The least ergonomic API of them all, ironically.
- **Jotai:** Provides a simpler API with atoms and hooks, reducing boilerplate and making it easier to manage state updates. Think of it as `useState` but global and can derive state from multiple atoms.
- **Zustand:** Offers a simple and flexible API with minimal boilerplate, making it easy to manage state updates. Similar to Mobx and slightly so with Redux.
- **Legend State:** Provides a declarative API, making it easy to understand and use for basic cases. Not as great when using the API for maximum performance (wrapping components in `observer`, which looks like old HoC code).

### Rendering performance

See this [benchmark](https://legendapp.com/open-source/state/v2/intro/fast/) from the Legend State docs for reference.

- **React Context:** Can cause entire component trees to re-render when the context value changes, leading to potential performance issues and unnecessary re-renders.
- **Jotai:** Minimizes unnecessary re-renders by only updating components that depend on the specific piece of state that has changed. Second fastest while retaining very familiar API to React devs.
- **Zustand:** Uses a subscription-based model to minimize unnecessary re-renders. Faster than Context but slower than the others in this comparison.
- **Legend State:** Optimizes rendering performance with a fine-grained reactivity system. The fastest of all options, with the caveat of the optimal code not looking like standard React.

### Scalability

- **React Context:** Can become difficult to manage as the state grows in size and complexity, leading to potential performance and maintainability issues.
- **Jotai:** Atom-based approach allows for better scalability, making it easier to manage and reason about the state.
- **Zustand:** Flexible API allows for easy management of complex state logic and large state trees. Easier Redux-like state management.
- **Legend State:** Declarative approach allows for easy management of complex state logic and large state trees. There's the caveat of the non-standard looking API for the optimal performant code.

### Community and support

- **React Context:** Built-in feature of React with a large community and ecosystem support.
- **Jotai:** Excellent community with good documentation and support, second only to Zustand (maintained by the same team as Jotai), but may not have the same level of ecosystem integration as React Context (since it's baked in React).
- **Zustand:** Excellent community with good support and documentation, maintained by the same team as Jotai, but again, may not have the same level of ecosystem integration as React Context
- **Legend State:** Fairly dedicated community with good documentation and support, but less so than the other options. Worse integration than the others as Legend State removes itself from React conventions to achieve top performance.

## Decision

We are considering switching to Jotai for global state management in our React application. Jotai provides a simpler and more intuitive API, better rendering performance, and improved scalability compared to React Context. While Zustand and Legend State also offer significant advantages, Jotai's balance of ease of development and performance makes it the best choice for our needs.

## References

- [React Context Documentation](https://reactjs.org/docs/context.html)
- [Jotai Documentation](https://jotai.org/docs/introduction)
- [Zustand Documentation](https://zustand.surge.sh/)
- [Legend State Documentation](https://legendapp.com/open-source/state/v3/)
9 changes: 6 additions & 3 deletions docs/adr/adr_frontend_global_state.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Author: HaGuesto

Early Discussion and research phase

Update: [Use Jotai for Global State Management](./adr-global-state-management.md)

## Context or Problem Statement

There are a few mutable global variables/shared states in the frontend that we want to access in all of the DOM. The basic example is something like `Dark Mode`. In our case, information about the bases and organisations a user has access to is best saved globally and not queried each time a new.
Expand All @@ -27,6 +29,7 @@ Since we are using Apollo for graphQL queries we can also use the Apollo Reactiv
## Considered Options

### React Context (with useReducer)

Usually, one writes a Reducer in addition to React Context when you handle global states. In some simple cases the `useState` hook should be enough, but is not recommended. The reasons are:

1. Predictable state changes: Reducers enforce a predictable way of changing state, which can help prevent bugs caused by unexpected state changes. Since reducers always produce a new state based on the previous state and an action, it's easier to reason about how the state changes in response to different actions.
Expand All @@ -47,7 +50,6 @@ Here are some general pros and cons to consider:
- can require more boilerplate code for setting up and managing the context and reducer functions
- can be less performant than other global state management approaches like Redux, especially if the state is deeply nested or frequently updated


### [Apollo local state](https://www.apollographql.com/docs/react/local-state/local-state-management)

Similar as for React Context, one should distinguish between the Reactive Variables of Apollo and working with local-only cache fields. Only the later is really recommended for global state changes. Similar to a Reducer you also have to define the read and write queries for local-only cache fields. Reactive Variables are very similar to the concept of the `useState` hook. Therefore, to the same reasons as before we only consider local-only fields here.
Expand All @@ -70,9 +72,10 @@ Here are some general pros and cons to consider:
React Context and Apollo can both and should both be used for global state management in React depending on the specific requirements of the feature.

### Comment
In general, a mix out of both considered options is most likely needed to handle mutable shared states. In some cases the Apollo has more advantages (especially for remote states), sometimes the React Context is better to use.

In general, a mix out of both considered options is most likely needed to handle mutable shared states. In some cases the Apollo has more advantages (especially for remote states), sometimes the React Context is better to use.
We should try out Apollo when the next mutable shared state comes around like for the QrReader when scanning multiple Boxes.
There is no need to refactor the Global Preference Provider at the moment. Removing the React Context of the Global Preference Provider and creatingthe same structure in Apollo for it, is just unnecassary work. The Global Preference Provider works and the code is clean.
There is no need to refactor the Global Preference Provider at the moment. Removing the React Context of the Global Preference Provider and creatingthe same structure in Apollo for it, is just unnecassary work. The Global Preference Provider works and the code is clean.

## Reference

Expand Down
1 change: 1 addition & 0 deletions front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@zxing/browser": "^0.1.5",
"@zxing/library": "^0.21.3",
"chakra-react-select": "4.9.2",
"jotai": "^2.10.3",
"react-big-calendar": "^1.17.0",
"react-icons": "^5.4.0",
"react-table": "^7.8.0",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useReactiveVar } from "@apollo/client";
import { useAtomValue } from "jotai";
import { boxReconciliationOverlayVar } from "queries/cache";
import { SHIPMENT_BY_ID_WITH_PRODUCTS_AND_LOCATIONS_QUERY } from "queries/queries";
import { UPDATE_SHIPMENT_WHEN_RECEIVING } from "queries/mutations";
Expand All @@ -14,7 +15,7 @@ import {
ILocationData,
IProductWithSizeRangeData,
} from "./components/BoxReconciliationView";
import { useBaseIdParam } from "hooks/useBaseIdParam";
import { selectedBaseIdAtom } from "stores/globalPreferenceStore";

export interface IBoxReconciliationOverlayData {
shipmentDetail: ShipmentDetail;
Expand All @@ -31,7 +32,7 @@ export function BoxReconciliationOverlay({
}) {
const { createToast } = useNotification();
const { triggerError } = useErrorHandling();
const { baseId } = useBaseIdParam();
const baseId = useAtomValue(selectedBaseIdAtom);
const boxReconciliationOverlayState = useReactiveVar(boxReconciliationOverlayVar);
const [boxUndeliveredAYSState, setBoxUndeliveredAYSState] = useState<string>("");
const navigate = useNavigate();
Expand Down
11 changes: 6 additions & 5 deletions front/src/components/BreadcrumbNavigation.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useContext } from "react";
import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider";
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, Button } from "@chakra-ui/react";
import { ChevronLeftIcon, ChevronRightIcon } from "@chakra-ui/icons";
import { Link } from "react-router-dom";
import { useLoadAndSetGlobalPreferences } from "hooks/useLoadAndSetGlobalPreferences";
import { BreadcrumbNavigationSkeleton } from "./Skeletons";
import { useAtomValue } from "jotai";
import { organisationAtom, selectedBaseAtom } from "stores/globalPreferenceStore";

interface IBreadcrumbItemData {
label: string;
Expand Down Expand Up @@ -35,9 +35,10 @@ export function MobileBreadcrumbButton({ label, linkPath }: IBreadcrumbItemData)
}

export function BreadcrumbNavigation({ items }: IBreadcrumbNavigationProps) {
const { globalPreferences } = useContext(GlobalPreferencesContext);
const orgName = globalPreferences.organisation?.name;
const baseName = globalPreferences.selectedBase?.name;
const organisation = useAtomValue(organisationAtom);
const selectedBase = useAtomValue(selectedBaseAtom);
const orgName = organisation?.name;
const baseName = selectedBase?.name;
const { isLoading: isGlobalStateLoading } = useLoadAndSetGlobalPreferences();

if (isGlobalStateLoading) return <BreadcrumbNavigationSkeleton />;
Expand Down
17 changes: 7 additions & 10 deletions front/src/components/HeaderMenu/BaseSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,24 @@ import {
RadioGroup,
Stack,
} from "@chakra-ui/react";
import { useContext, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";

import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider";
import { useBaseIdParam } from "hooks/useBaseIdParam";
import { useAtomValue } from "jotai";
import { availableBasesAtom, selectedBaseIdAtom } from "stores/globalPreferenceStore";

function BaseSwitcher({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const navigate = useNavigate();
const { pathname } = useLocation();
const { baseId: currentBaseId } = useBaseIdParam();
const { globalPreferences } = useContext(GlobalPreferencesContext);
const currentOrganisationBases = globalPreferences.availableBases?.filter(
(base) => base.id !== currentBaseId,
);
const baseId = useAtomValue(selectedBaseIdAtom);
const availableBases = useAtomValue(availableBasesAtom);
const currentOrganisationBases = availableBases?.filter((base) => base.id !== baseId);
const firstAvailableBaseId = currentOrganisationBases?.find((base) => base)?.id;
const [value, setValue] = useState(firstAvailableBaseId);

// Need to set this as soon as we have this value available to set the default radio selection.
useEffect(() => {
setValue(firstAvailableBaseId);
}, [firstAvailableBaseId, currentBaseId]);
}, [firstAvailableBaseId, baseId]);

const switchBase = () => {
const currentPath = pathname.split("/bases/")[1].substring(1);
Expand Down
7 changes: 4 additions & 3 deletions front/src/components/HeaderMenu/HeaderMenuContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useMemo } from "react";
import { useAtomValue } from "jotai";
import { useReactiveVar } from "@apollo/client";
import QrReaderOverlay from "components/QrReaderOverlay/QrReaderOverlay";
import { qrReaderOverlayVar } from "queries/cache";
import { useReactiveVar } from "@apollo/client";
import { useAuthorization } from "hooks/useAuthorization";
import HeaderMenu, { MenuItemsGroupData } from "./HeaderMenu";
import { useBaseIdParam } from "hooks/useBaseIdParam";
import { selectedBaseIdAtom } from "stores/globalPreferenceStore";

function HeaderMenuContainer() {
const authorize = useAuthorization();
const { baseId } = useBaseIdParam();
const baseId = useAtomValue(selectedBaseIdAtom);
const qrReaderOverlayState = useReactiveVar(qrReaderOverlayVar);

// TODO: do this at route definition
Expand Down
19 changes: 11 additions & 8 deletions front/src/components/HeaderMenu/MenuDesktop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,27 @@ import { NavLink } from "react-router-dom";

import { ACCOUNT_SETTINGS_URL } from "./consts";
import { useHandleLogout } from "hooks/hooks";
import { GlobalPreferencesContext } from "providers/GlobalPreferencesProvider";
import BoxtributeLogo from "./BoxtributeLogo";
import { IHeaderMenuProps } from "./HeaderMenu";
import MenuIcon, { Icon } from "./MenuIcons";
import { expandedMenuIndex } from "./expandedMenuIndex";
import { useContext } from "react";
import BaseSwitcher from "./BaseSwitcher";
import { useBaseIdParam } from "hooks/useBaseIdParam";
import { useAtomValue } from "jotai";
import {
availableBasesAtom,
selectedBaseAtom,
selectedBaseIdAtom,
} from "stores/globalPreferenceStore";

function MenuDesktop({ menuItemsGroups }: IHeaderMenuProps) {
const { isOpen, onOpen, onClose } = useDisclosure();
const { handleLogout } = useHandleLogout();
const { baseId: currentBaseId } = useBaseIdParam();
const { globalPreferences } = useContext(GlobalPreferencesContext);
const baseName = globalPreferences.selectedBase?.name;
const baseId = useAtomValue(selectedBaseIdAtom);
const selectedBase = useAtomValue(selectedBaseAtom);
const availableBases = useAtomValue(availableBasesAtom);
const baseName = selectedBase?.name;
const currentOrganisationHasMoreThanOneBaseAvailable =
(globalPreferences.availableBases?.filter((base) => base.id !== currentBaseId).length || 0) >=
1;
(availableBases?.filter((base) => base.id !== baseId).length || 0) >= 1;
const [allowMultipleAccordionsOpen] = useMediaQuery("(min-height: 1080px)");

return (
Expand Down
Loading