Redux: Global State
The V2 frontend now uses Redux to manage global state. Redux has a pretty good guide here which I recommend having a read through to understand what Redux is (Part 1) and how to use it (Part 2). This page is just going to give an overview of how to use it within Freerooms.
At the time of writing, the separate pieces of state (slices) currently in our Redux store are:
currentBuilding
- the Building shown on the sidebardatetime
- the Date object that is used when fetching the statusfilters
- the Filters to be used when filtering rooms and buildings
Interacting with the Redux state centres around two hooks: useSelector()
and useDispatch()
. You can import them from redux/hooks
.
import { useDispatch, useSelector } from "path/to/redux/hooks";
Make sure you don’t use the default useSelector()
and useDispatch()
from react-redux
because they aren't typed correctly.
useSelector()
: Accessing state
useSelector()
takes a selector function which it uses to obtain a value from the state. The state is basically one big nested object that stores all the values that make up the global state, so a selector function is used to simplify traversing through this object.
Each separate slice exports a selector function to obtain the value of that slice. For example, in redux/currentBuildingSlice.ts
the selector selectCurrentBuilding()
is exported.
To access the currentBuilding
state in a component, we import the appropriate selector and then pass that to useSelector()
. Here’s an example
import { selectCurrentBuilding } from "path/to/redux/currentBuildingSlice";
import { useSelector } from "path/to/redux/hooks";
const ExampleComponent = () => {
const currentBuilding = useSelector(selectCurrentBuilding);
return (
<div>The current building is {currentBuilding}</div>
)
}
At the time of writing, the selectors we have are:
hooks/currentBuildingSlice.ts
exports the selectorselectCurrentBuilding()
hooks/datetimeSlice.ts
exports the selectorselectDatetime()
hooks/filtersSlice.ts
exports the selectorselectFilters()
useDispatch()
: Updating state
useDispatch()
takes an action and “dispatches” it to the store. Actions are an object that look something like {type: string, payload?: any}
, and describe what type of update we are performing (e.g. set currentBuilding, or clearFilters) and the payload for that action (e.g. the new currentBuilding).
Luckily, there’s no need to manually create actions because each slice exports action creator functions for each type of update we might want to perform on the slice. For example, in hooks/filtersSlice.ts
we export three action creators - setFilter()
, unsetFilter()
and clearFilters()
.
To update the state, import the appropriate action creator, call it, then pass the result to useDispatch()
. For example:
import { clearFilters, setFilter, unsetFilter } from "path/to/redux/filtersSlice";
import { useDispatch } from "path/to/redux/hooks";
const ExampleComponent = () => {
return (
<div>
<button onClick={() => useDispatch(setFilter({ key: "usage", value: "LEC" }))}>
Filter lecture halls
</button>
<button onClick={() => useDispatch(unsetFilter("usage"))}>
Unset room usage filter
</button>
<button onClick={() => useDispatch(clearFilters())}>
Clear Filters
</button>
</div>
)
}
At the time of writing, the action creators we have are:
hooks/currentBuildingSlice.ts
exports the action creatorsetBuilding()
hooks/datetimeSlice.ts
exports the action creatorsetDatetime()
hooks/filtersSlice.ts
exports the action creatorssetFilter()
,unsetFilter()
andclearFilters()
Adding more state
When adding new pieces of state to Redux, make sure that it’s actually something that needs to go there. If it’s only used in your component or children of your component, then it’s probably better to just use a useState
hook. If it relies on backend data, then you may want to use/create an SWR data fetching hook.
To add a new piece of state to Redux, we need to create a new slice. As an example, we’ll walk through how we created the filtersSlice
.
The first thing we want to do is declare the type of this slice. Generally, this should be an object containing the key “value” whose type is the type of the state we want to store (more on why we need to put it in an object later). Here is the type we created for filtersSlice
:
interface FiltersState {
value: Filters
}
Next, we need to declare the initial state of this slice. In this case, the initial filters are nothing:
const initialState: FiltersState = {
value: {}
};
Now we can create a slice. We’ll use the createSlice()
function from Redux toolkit and give it a name (for debugging), the initial state, and an object containing our reducers (empty for now):
import { createSlice } from "@reduxjs/toolkit";
const filtersSlice = createSlice({
name: "filters",
initialState,
reducers: {}
});
Now we want to create reducers. We create a reducer for every action (state update) we want to perform, which takes the previous state and the action’s payload and dictates how we should use the payload to update the state.
Our first reducer is unsetFilter()
which takes a key and unsets the filter that they key refers to. We'll add this to our reducers object like so:
reducers: {
unsetFilter: (state, action: PayloadAction<keyof Filters>) => {
if (Object.keys(state).includes(action.payload)) {
// otherFilters contains all keys besides action.payload
const { [action.payload]: unset, ...otherFilters } = state.value;
state.value = otherFilters;
}
}
}
Let’s create some more reducers. We can create reducers that have no payload:
clearFilters: (state) => {
state.value = {};
}
We can also create reducers that take multiple values as a payload. We can technically only take in one argument though, so this payload should be an object:
setFilter: (state, action: PayloadAction<{ key: keyof Filters, value: string }>) => {
const { key, value } = action.payload;
state.value[key] = value;
}
And now our slice is done, all that’s left is to export stuff! First we export the action creators, which we can obtain from the actions
field of our slice. The keys match the reducers we created.
export const { setFilter, unsetFilter, clearFilters } = filtersSlice.actions;
Next we export the selector(s). We access the state object for this slice through state.key
where key
is the key we use in the next step to add this slice to the store. Note that this will throw type errors until we do the next step.
export const selectFilters = (state: RootState) => state.filters.value;
Finally, we want to add the slice to the store. We first need to export the combined reducer for this slice
export default filtersSlice.reducer;
then import it in redux/store.ts
and add it to the reducers
field in configureStore()
:
import filtersReducer from "./filtersSlice";
const store = configureStore({
reducer: {
currentBuilding: currentBuildingReducer,
datetime: datetimeReducer,
filters: filtersReducer // here!
},
...
}
Related content
UNSW CSESoc