Verified Commit a897a98f authored by Florent Chehab's avatar Florent Chehab
Browse files

docs(frontend): how to use the global state

* also dropped reference to redux
parent f065d821
# Global State Handling
## Introduction
As you may know, react components have a strict _living_ period and as soon as they are _unmounted_ from the virtual DOM the data they contained in their **state** is completely deleted.
As a developer, there are often cases when you would like react components to share data between together or to persist the component's state. One of the way to accomplish that is by "lifting the state up" (in the component tree) which is not always practical nor optimal.
Another possibility is to use React's `Context` API (used in some places in REX-DRI) ; but it's hard to generalize / use.
?> But why not redux?
If you are familiar with Javascript frontend app development you may be familiar with `Redux` (and `react-redux`). Those libraries provide powerful global state management to your applications.
Still `react-redux` can be a bit hard to optimize; and `reducer` and `action` principles at his core can be a boilerplate nightmare for the developer (redux is really a "white elephant").
> **That's why, as of the version `2.0.0` of this project, we don't use redux anymore and now rely purely on customized react-hooks to persist and share states across components.**
## The useGlobalState hook
You will find in the project the `useGlobalState(key, initialValue)` hook.
```jsx
import useGlobalState from "...";
function MyComponent() {
const [state, setState] = useGlobalState("my-counter", 0);
return <button onClick={() => setState(state + 1)}>Counts: {state}</button>;
}
```
It behaves similarly as react's `useState` hook except:
- The state of that hook is persisted in a `globalState` (a Javascript variable)
- So anything can access that state
- The state is identified by a `key`, so that the previous state is retrieved on component re-mount.
- This state is shared by any component using the `useGlobalState` hook with the same `key`
- **In such a way that any update to that state in any component will update the state value in all other components that are using it.**
> **It's basically that simple.** As a developer you simply need to be careful about the `key` you use so that there is no overlapping possible.
## The useGlobalReducer hook
To mimic `redux` use of reducers, you can also use the `useGlobalReducer(key, reducer, initialState)` hook to perform update on the global / shared state of the app.
?> Under the hood `useGlobalReducer` uses `useGlobalState` so same notes applies here.
Here is basically the same example as previously with the `useGlobalReducer` hook.
```jsx
import useGlobalReducer from "...";
const ACTION_INCREMENT = "ACTION_INCREMENT";
const myReducer = (action, previousState) => {
if (action === ACTION_INCREMENT) return previousState + 1;
else return previousState;
};
function MyComponent() {
const [state, dispatch] = useGlobalReducer("my-counter", myReducer, 0);
return (
<button onClick={() => dispatch(ACTION_INCREMENT)}>Counts: {state}</button>
);
}
```
The idea is depending on the **action** you **dispatch** the associated **reducer** will compute the new **state** value based on the **previous state** value.
!> Reducers and `useGlobalReducer` are helpful when you have different and specific _actions_ that can be applied to a state. It is kind of a stricter and more explicit way of using the global state ; but it also more verbose.
Use of redux
======
?> `Redux` is a predictable state container for JavaScript apps.
Why redux?
As you may now, react components have a strict living period and as soon as they are *unmounted* from the DOM the data they contained in their state is completely deleted. The redux object (called a *store*) on the other hand will live as long as your app is open in the browser so it’s a nice place to store information in.
*NB: it lives as long as your browser is open because it usually is the root component of your react app, as you can see in `frontend/src/index.js`*.
Redux solves another issue which is that sometimes different components in different locations of your *virtual DOM* need to access similar data. So redux is very useful to store data from a backend API.
## How it works
To understand how redux works, you need to understand 2 principles: *actions* and *reducers*.
!> *Actions* are objects/functions your app *dispatches* and that contains a `type` (to identify the action) and some optional contextual data.
!> *Reducers* are objects/functions that listen to the *actions* being dispatched and update the redux `store`'s **state** depending on the action and the contextual data they provide.
### Example of an action
*Taken from `frontend/src/redux/actions/map.js`.*
```js
export function saveMainMapStatus(newStatus) {
return {
type: SAVE_MAIN_MAP_STATUS,
newStatus
};
```
This action is of type `SAVE_MAIN_MAP_STATUS` (defined in `frontend/src/redux/actions/action-types.js`) and is called with a *new status* (a javascript Object) as contextual data.
This action is used to save the state of the map (what is the zoom level, what are the coordinates of the center and what layer is selected) in our application.
### Example of a reducer
To interpret this action when it is *dispatched* we have the following *reducer*:
```js
export function saveMainMapStatus(state = { center: [0, 2], zoom: 2, selectedLayer: "OpenStreetMap France" }, action) {
switch (action.type) {
case SAVE_MAIN_MAP_STATUS:
return {
center: action.newStatus.center,
zoom: action.newStatus.zoom,
selectedLayer: action.newStatus.selectedLayer
};
default:
return state;
}
}
```
So a *reducer* is given the previous state of the redux *store* (or here a portion of the store, this will be explained later) and an *action*. If the action "concerns" the reducer then it returns a new state based on the action contextual data; otherwise, the state is returned unchanged.
The idea is that we have tons of reducers in our app, and we compose them to have a better organization. So each of those reducers will be called for all dispatched actions, hence the simple `switch` statement to check if this reducer is concerned by the action.
### General shape of our app's redux store
In *REX-DRI*, all the reducers are composed in such a way that the final store looks like this:
```js
{
app: {
// data that concerns the app and only the app
},
api: {
// data that concerns all the endPoint provided by the backend API
}
}
```
The data in `app` is built with reducers handwritten, just like the one above. The data in `api` is based on reducers generated automatically at runtime; more on this [later](/Application/Frontend/redux?id=dynamic-actions-and-reducers).
## Accessing the data in the redux store
### Direct access
A direct access to the redux store is possible and very simple: you need to call the `getState` method on the store defined in `frontend/src/redux/store.js`. `getState` returns the full current state of your store.
```js
import store from "../store";
const myData = store.getState().app.//....
```
?> :information_desk_person: some helpers functions have been built in `frontend/src/redux/api/utils.js` to handle this access more easily in some circumstances.
Such a direct access is, however, not always interesting...
### Subscribing to the store
Redux make it possible to make a component subscribe to the store or a portion of it, and when it's modified, then your component will be automatically updated. When we say "subscribe" we mean that:
- Redux will add the `props` you tell him to your components,
- Those props map to a portion of the redux store (/state),
- Every time that portion of the store is updated, the props on your components will be updated,
- And finally, given how react is built and because your props have been updated, your component will be updated. :confetti_ball:
Here is an implementation example:
```js
import React, { Component } from "react";
import { connect } from "react-redux";
class UnivMap extends Component {
render(){
return (<>{this.props.map}</>)
}
}
const mapStateToProps = (state) => {
return {
map: state.app.mainMap
};
};
export default connect(mapStateToProps)(UnivMap);
```
When exporting your component, you wrap it with the `connect` function provided by redux. This function takes as argument another `mapStateToProps` function.
`mapStateToProps` is a function that takes as argument the state (of the redux store) and returns an object with key(s) the `props` that will be added to your component and value(s) the portion(s) of the store you are interested in (and that will be accessible from the `props`).
And then everything is automated! :confetti_ball:
## Putting data in the store
Well, it's nice to be able to read data from the redux store, but it would be even cooler to be able to put data in the store.
There comes in the actions (and indirectly the reducers) you have defined earlier :smile:.
The idea is that you want your component to be able to *dispatch* actions (that will be handled by the reducers and that will update the state of the redux store).
This is done once again with redux `connect` function:
TODO example outdated with the new map system.
```js
import React, { Component } from "react";
import { connect } from "react-redux";
import { saveMainMapStatus } from "../../redux/actions/map";
class UnivMap extends Component {
componentWillUnmount() {
// ...
this.props.saveMainMap({
zoom: leafletInstance.getZoom(),
center,
selectedLayer
});
// ...
}
render() {
return (<> />)
}
}
const mapDispatchToProps = (dispatch) => {
return {
saveMainMap: (mapState) => dispatch(saveMainMapStatus(mapState)),
};
};
export default connect(() => {}, mapDispatchToProps)(UnivMap);
```
So this time, we have the `mapDispatchToProps` function to pass to the `connect` function from redux.
`mapDispatchToProps` takes as argument the *dispatch* capability provided by redux and returns an object. Once again, the key(s) of this object will be mapped to your component's `props`. Those new `props` will be functions that take optional arguments and then *dispatch* a specific action.
That's all! :confetti_ball:
?> :information_desk_person: as you can see the `connect` function from redux takes as first argument a `mapStateToProps` function and as second argument a `mapDispatchToProps` function. Providing the second one is optional. To provide only the second one, you can put `() => {}` (useless function) as first argument.
## Redux and the backend API
!> :warning: This section if probably the most important one if you are already familiar with redux. Because we have tons of backend API endpoints, we have implemented a generic way to use them with redux. :warning:
### Dynamic actions and reducers
As you can see above, writing actions and reducers is very verbose and redundant, especially if you have a lot of them that only differ from a name or endpoint.
So... actions related to the API are generated at runtime based on all available endpoints. The same goes for the associated *reducers* that are then combined under `state.api` in the redux store. :confetti_ball:
?> :information_desk_person: "all available endpoints" are *hard-coded* in the (only) HTML page returned by Django (see `backend/base_app/views.py`).
Since those actions are generated dynamically, they are not available while coding. To use those actions you need to use the `getActions` helper from `frontend/src/redux/api/getActions.js`. This function takes as parameter the endpoint you want to fetch or write to, and returns an object with different possible actions. Here is an example:
```js
import getActions from "../../redux/api/getActions";
// ........
const mapDispatchToProps = (dispatch) => {
return {
api: {
universities: () => dispatch(getActions("universities").readAll()),
}
};
};
// ......
```
*NB: more on why we put it a sub-object `api` in the next section.*
The `getActions` function will give you access to all of the following functions (which are defined in the `CrudActions` class -- `frontend/src/redux/api/CrudActions.js`):
- `readAll(params=RequestParams.Builder.build())`
- `readOne(params)`
- `create(params)`
- `update(params)`
- `delete(params)`
?> `params` **must be an instance of `RequestParams`**, which is a helper class defined in the project. This class comes with a handy `Builder` static class (say hello to the Builder design pattern) to help you parameterize your requests. Here is a quick summary of the functions provided by the builder (all are *optional*):
* `withId(id)`: specify the id that will be added to the url (eg: `withId(1)` => `/endpoint/1`)
* `withData(data)`: specify the payload that will go with request (useful for creating/updating models instances)
* `withQueryParam(key, value)`: add a *query param* to the request object (eg: `withQueryParam("currency", "CHF")` will result in the request `/endpoint?currency=CHF`); you can chain multiple `withQueryParam`.
* `withEndPointAttrs(endPointAttrs)`: `endPointAttrs` should be an array of the endpoint attributes to add to the endpoint (`withEndPointAttrs([10, 11])` will render as `/endpoint/10/11/`).
* ` withOnSuccessCallback(callback)`: register a callback that will be called when the request is successful. *The data returned by the server will be passed as parameter to this callback.*
* `build()`: to conclude the building process :smile:
?> :information_desk_person: All those functions can be chained.
!> Never forget the `.build()` at the end of your chain.
You also have actions to clear the failures if you need:
- `clearReadAllFailed()`
- `clearReadOneFailed()`
- `clearCreateFailed()`
- `clearUpdateFailed()`
- `clearDeleteFailed()`
And actions related to invalidating the data:
- `invalidateAll()`
- `clearInvalidationAll()`
- `invalidateOne()`
- `clearInvalidationOne()`
!> Not all actions might be performed on any given endpoint: your request my get rejected by the backend depending on the viewset's `permission_classes`, the object, the user who sent the request, etc.
?> :information_desk_person: invalidating data will usually trigger a refresh of that data with a new API call. This behavior is implemented in `CustomComponentForApi`
?> Do you recall that an update to the redux store will be propagated to the components that imported that portion of the store (concerned by the update). So they will be updated on their own every time new data comes in, like magic! :confetti_ball:
!> If a viewset is *configured* with endpoint `end_point_route = "universities"`, then in `getActions` you will have access to it by specifying that exact endpoint, ie `getActions("universities")`.
!> `...One` and `...All` are stored in distinct location in the the redux store.
### Conventions for reading data
If you are familiar with network request, you will know that those are "async", and that they can go through different states: from `isReading` to `readSucceeded` or `readFailed` for instance. We need to keep track of those state so that the UI is coherent and let the user know what is the current state. Therefore, all those different states are stored for all possible actions (and for all possible endpoints) in... the redux store :confetti_ball:.
As a result, the real data returned by the endpoint will usually be stored under `...Succeeded.data` state portion.
To handle all those specific behaviours and easily use the data from the backend API (stored in the redux store under nasty path), a nice class has been designed: `CustomComponentForAPI`. It inherits from `React.Component` and you can use it as a standard component, except that you must call the `customRender` function and not the `render` function you are used to with standard react components.
?> :information_desk_person: You should use this class only if you need to fetch data from the frontend.
The `CustomComponentForAPI` class provides nice functions to access the data. For instance, `getLatestReadData(propName)` will return the latest data read for a `prop` (mapped by redux `connect` with `mapStateToProps`) identified by `propName` (ie: the *key* of the `prop`). You should look at all the other functions available in this class :wink:.
For the `CustomComponentForAPI` to work properly, i.e. for it to fetch the needed data, display loading indicator and retrieve the data quickly you need to follow this convention:
```js
const mapStateToProps = (state) => {
return {
propName: state.api.whateverAll // or whateverOne
};
};
const mapDispatchToProps = (dispatch) => {
return {
api: {
propName: () => dispatch(getActions(//...
}
};
};
```
!> **In `mapStateToProps`, for the `props` that will correspond to data fetched from the API, you must not go in the details of the state: we need to have access to `isReadingAll`, etc.**
!> **The function that will be used to dispatch the action associated with fetching the data for `propName` must be identified by `api.propName` in the `mapDispatchToProps` function.**
#### Dynamic parametrization of the requests
Often, you will need to access data on the API based on a value stored in the `props` of your components. As a result you need to generate a new `RequestParams` object on demand.
:warning: To do so, in your component you should define he **`apiParams`** property like follow:
```js
apiParams = {
countryDri: ({props, state}) => RequestParams.Builder.withQueryParam("countries", props.countryId).build()
};
```
`apiParams` is an object with keys the `propName` (we have been talking about before) and value a function that accepts an object and that **must return** a `RequestParams` instance.
Then, automatically and when needed, a new `RequestParams` will be built by the internal functions of `CustomComponentForApi`; as you can see this *mapper* function takes as an argument an object with two keys that corresponds to the current `state` and `props` of the object.
When using `apiParams`, your `mapDispatchToProps` should look like this:
```js
const mapDispatchToProps = (dispatch) => {
return {
api: {
countryDri: (params) => dispatch(getActions("countryDri").readAll(params)),
},
};
};
```
Your function must take one argument (`params`) (that will be automatically built) and pass it down to the action like a breeze.
?> :information_desk_person: For all "dynamic" attributes (defined inside `apiParams`) some awesome magical behaviors will be automatically inherited, such as the fact that a new request will be made to the server if the parametrization has changed (e.g. if a `prop` has changed) without needing to detect it yourself (only on ). :tada: This behavior is only present on `ComponentDidMount` react hook. If you want to activate it on `ComponentDidUpdate`, you should set `enableSmartDataRefreshOnComponentDidUpdate = true` as a component property.
That's all :confetti_ball:.
### Words on updating data on the API
TODO
### Troubleshooting
- Open your console and look at the actions and resulting states that are being logged: explore.
- As explained earlier, the values from the api get be read either for "all" or a "one" instance. So you need to specify the one you are interested in when getting the action (eg: `.readAll` or `.readOne`) and you need to specify the matching one you are retrieving from the redux state: `state.api.whateverAll` or `state.api.whateverOne`.
### Under the hood
You can look at the files in `frontend/src/redux/api` to know more about how all of this works.
......@@ -2,45 +2,42 @@
- Getting started
* [Introduction](GettingStarted/introduction.md)
* [Set-up](GettingStarted/set-up.md)
* [Initializing data](GettingStarted/init_data.md)
* [IDE Setup](GettingStarted/IDE_setup.md)
- [Introduction](GettingStarted/introduction.md)
- [Set-up](GettingStarted/set-up.md)
- [Initializing data](GettingStarted/init_data.md)
- [IDE Setup](GettingStarted/IDE_setup.md)
- Application documentation
* Application documentation
- Backend
* [Architecture](Application/Backend/architecture.md)
* [Models, Serializers and ViewSets](Application/Backend/models_serializers_viewsets.md)
* [Config files](Application/Backend/config_files.md)
* [Moderation & versioning](Application/Backend/moderation_and_versioning.md)
* [Data validation](Application/Backend/data_validation.md)
* [API](Application/Backend/API.md)
* [External data](Application/Backend/external_data.md)
* [Tests](Application/Backend/tests.md)
- [Architecture](Application/Backend/architecture.md)
- [Models, Serializers and ViewSets](Application/Backend/models_serializers_viewsets.md)
- [Config files](Application/Backend/config_files.md)
- [Moderation & versioning](Application/Backend/moderation_and_versioning.md)
- [Data validation](Application/Backend/data_validation.md)
- [API](Application/Backend/API.md)
- [External data](Application/Backend/external_data.md)
- [Tests](Application/Backend/tests.md)
- Frontend
* [React basics](Application/Frontend/react.md)
* [Use of Redux](Application/Frontend/redux.md)
* [Tests](Application/Frontend/tests.md)
* [Services](Application/Frontend/services.md)
* [Map](Application/Frontend/map.md)
* [Troubleshooting](Application/Frontend/troubleshooting.md)
- [React basics](Application/Frontend/react.md)
- [Global State Handling](Application/Frontend/global_state.md)
- [Tests](Application/Frontend/tests.md)
- [Services](Application/Frontend/services.md)
- [Map](Application/Frontend/map.md)
- [Troubleshooting](Application/Frontend/troubleshooting.md)
- [**Deploy**](Application/deploy.md)
- Comments about technologies used
* [Use of `Docker`](Technologies/docker.md)
* [Debugger](Technologies/debugging.md)
- [Use of `Docker`](Technologies/docker.md)
- [Debugger](Technologies/debugging.md)
- Other
* Other
* [About this documentation](Other/this_doc.md)
* [Contributions](Other/contributions.md)
* [Credits](Other/credits.md)
- [About this documentation](Other/this_doc.md)
- [Contributions](Other/contributions.md)
- [Credits](Other/credits.md)
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment