Redux: Everything under the UI
Let's create everything under the UI for our epic clone project.
Once we get our infrastructure under our UI right, plugging UI components will become a breeze.
Repository
Clone the start branch to follow. Clone the final branch to follow the concepts skimming through the code quickly.
// Repository
github.com/karthickthankyou/epic-clone// Clone start branch:
git clone -b 04-create-slices-start github.com/karthickthankyou/epic-clone// Clone final branch:
git clone -b 04-create-slices-final github.com/karthickthankyou/epic-clone
Dependencies
If you choose to follow with the start branch, Install the below dependencies.
npm i algoliasearch firebase react-router-dom slugifynpm i -D @types/react-router-dom
Mental Model: Units of Exchange
The below article sets up the mental model we use to simplify any. react application.
Any module be it a UI component or a Redux slice will export or import any one of the below.
- Selectors
- Actions
- Hooks
Let's jump in and create our Slices first.
User Slice
This independent slice manages the authentication by exposing the below selectors, actions, and hooks.
We index the consumables of this slice that lists the
State
A redux slice can be compared to a database and a REST API that interacts with the database.
Any slice must have a state. We choose to use the uid
and displayName
.
Any async data we deal with have the structure of data
, fulfilled
, loading
, and error
where the data will contain the required type.
Look below to see how to use Typescript Generics to create these Async types.
The below article will discuss in detail how generics work using the very above example.
Actions
Actions typically write or modify the state.
We have five async actions as shown in the below file. They all use firebase auth functions.
Have a quick look at the below script.
CreateAsyncThunk
We wrap the functions with createAsyncThunk to include them within the redux flow. We don't have to wrap these functions with try-catch as we handle them within extra reducers.
Look at the extraReducers property in the createSlice function.
We can deal with the resolutions pending, fulfilled, and rejected network states here.
We use javascript currying to simplify the above file. We discuss the exact example in the below article. Have a quick skim if you don’t understand how the above setStatus function works.
Synchronous actions
The User slice has setUser as the only synchronous action. It is synchronous for the redux as we use firebase auth listener from a custom react hook and we update user slice synchronously.
We then export the synchronous actions and the overall reducer as below.
export const { setUser } = userSlice.actions
export default userSlice.reducer
Hidden Immutability
Redux toolkit uses ImmerJS in its reducers making the updates simple.
In the reducers
property, the setUser
action takes two arguments the state and the action. The state’s type clearly shows that it is a writable draft by ImmerJS WritableDraft<AsyncUser>
.
Read more about immutability and Immerjs in the below articles.
The importance of PayloadAction type
If we don’t specify action: PayloadAction<User|null>
to our action parameter, by default, the type of action payload will be any
as the redux toolkit does not know what to expect in any of these actions.
So it becomes necessary to manually set the type of actions’ payload. We consider types as checkpoints throughout our application and we don’t want an any
at this crucial part.
Hooks
On signin
async action, we would have returned a value for our extraReducer if we were using any other auth services that don’t provide a listener.
But firebase provides onAuthStateChanged
listener that listens to any authentication-related state change.
This is a simple react custom hook using useEffect
with the following steps.
- The hook has an empty dependency list. So it runs only when the component mounts.
- We create a subscription to the listener and
dispatch(setUser({/* data */}))
whenever the auth state changes. - Returns unSubscribe that detaches the auth listener as a cleanup function that runs when the component unmounts.
So just running the custom hookuseUserListener()
on the home page will make sure the User slice will get informed about the user’s auth status.
Connect the slice to the store
Till now we are dealing with one domain. We need to connect this to the global store.
We use configureStore
function to list all the slices we have in our application under reducer property.
Selectors
What selectors do the outer world(in the project) expect from this user domain?
Selectors narrow down to the required state from the RootState
because that is how the useSelector
of react-redux works. I don’t know if we can export a narrowed-down version of a selector hook that starts with a particular slice.
Look how we can consume that selector from a component.
The user
will have an implied type of AsyncUser
.
That’s it. Our User slice is ready!
Games
The game slice is pretty elaborate. The below shows the index file. I suggest you look at the types we created for the game to know possible values we can have for genres, sections, etc.
We use the same techniques we used for the previous slice. Let's discuss some new points here.
Dynamic prop in slice’s state
Let's look at the type and the state of this slice.
[key in GameGenre]
as the prop’s key will let us have zero to n number of keys from the type GameGenre
.
This makes the development flexible.
Default values for state props
Also, notice the defaultAsyncGame
and defaultAsyncGames
. They make sure at any point in execution, the structure of the data is preserved.
The UI doesn’t have to check for undefined or other falsy values.
We use generics to dynamically create the default values for multiple types. The above example shows two.
Async actions that return value
In the last slice, we saw how we deal with async action using createAsyncThunk
and expecting the outcomes in extraReducers
property. But those actions did not return a payload value. All we did was set up loading, fulfilled, and error state.
Notice we return document.data()
. Let’s see how to deal with this returned value in our game slice.
Notice the types of input and output of the action getGamePage
. (src/store/user/userActions.ts)
.
- We explicitly provided the input argument’s type as
Game[‘id’] | null
. But typescript implicitly got the return types from the function implementation asGame | null
.
Now let’s visit the extraReducers (src/store/user/userSlice.ts)
withDefaultGame
avoids redundant state assignment in multiple cases.
Whatever we return from the createAsyncThunk
will be provided at the action.payload
(line 8)
ExtraReducer with builder instance
The syntax of extraReducer can be offputting. If you want to avoid this extra vocabulary builder
and addCase
you can go for the simple object notation with dynamic property keys.
extraReducer:{
[getGamePage.pending]: (state, action)=> {}
[getGamePage.fulfilled]: (state, action)=> {}
[getGamePage.rejected]: (state, action)=> {}
}
But the action argument will then lose the type
information of the payload.
The recommended way of using extraReducers is to use a callback that receives a ActionReducerMapBuilder instance. — Redux-toolkit documentation
By using the builder syntax, we can see the type of the action as below. It carries the types of arguments and the returned values of the getGamePage
action.
Dynamic property key in ExtraReducer
Our application deals with twenty-nine genres
like Action
, Adventure
, etc. We can not write an extraReducer case for all those genres separately.
Let’s look at the action getGamesGenre
.
- Input:
property
of typeGameGenre
- Output:
{ games }
of typeGame[]
Usage:
dispatch(getGamesGenre({ property: 'Action' }))
Look at the extraReducer. It is very similar to the setGamePage action.
The type of the state.genre
has 29 values. And a simpler implementation like below will reduce a lot of redundant code.
.addCase(getGamesGenre.fulfilled, (state, action) => {
const { property } = action.meta.arg
const { games } = action.payload
state.genres[property as GameGenre] = {items: games}
})
Two takeaways
- Access the input arguments from
action.meta.arg
- Assign state using dynamic keys like
state.genres[property as GameGenre]
Exposing Actions vs Hooks
Traditionally in redux unidirectional flow, we only deal with incoming data and outgoing actions. The hooks we expose here are simple react custom hooks that dispatch outgoing actions.
Then why hooks? Look at the below example.
We run this hook on the product page like below.
useGetGamePage(gameId)
With that one line, we make sure
- We fetch the game details.
- We re-fetch game details when the gameId changes. (The dependency array has [gameId])
- The game data gets cleaned up by dispatching
getGamePage(null)
when the component unmounts. If we don’t do this, the outdated data will be showing when we navigate into the page.
That is a decent abstraction from the UI.
Look at the next hook.
This hook just dispatches a whole bunch of actions together. Notice the action getGamesGenre
we discussed earlier (lines 9, 10, 11).
All enhancements get abstracted inside the hook: This could be a naive implementation. Imagine if we want to delay the data fetch of particular sections by listening to the scroll events, we will implement that inside this hook.
Whatever the approach is, The home page component simply needs to do this.
useGetHomeGames()
And the desired outcome will happen.
Just to summarize, Our thought process about hooks vs actions is,
- Actions to be dispatched on-demand at unpredictable times → We export them as plain actions.
- Actions that need to be dispatched in sync with the component’s life cycle → We bundle them inside custom hooks.
Note: The actions in the game slice are internally dispatched by the hooks themselves. We can even remove them from this index file. But we leave them for flexibility.
The result of the games domain looks like below.
III. BrowseGames
This data comes from algolia a full-text-search service. We hide that implementation inside this slice and expose the below things to the UI to consume.
Notice the huge list of selectors and actions that deal with browse filters. You may feel that they belong in the filter component. Why are we shuttling them to and fro the store?
The UI might seem busy in the picture. Even though the useSearchHook technically exists in the UI, the whole logic of the hook is managed inside src/store/browseGames
store.
It is streamlined. From UI’s perspective,
- Get all the data including the filters, searchTerm, and search results, and render accordingly.
- Simply turn on the hook useAlgoliaSearchGames() in the component. It hits algolia whenever any dependent selector changes.
- When the user fiddles around the filters, searchTerm or categories, call the appropriate action.
The cycle runs happily with the independent units doing their dedicated responsibilities.
UserGames
UserGames manages the games that the users have in their wishlist, cart, and library.
This domain is real-time like the first one with the auth listener. We want the firestore to notify us about the wishlist, cart, and library content in real-time.
The one action updateUserGames
will be used when the user adds or removes games from the wishlist and cart.
This domain also has a lot of hidden actions like setCartGameIds, setRemovedFromCartGameIds, etc. which are internally used by the hooks.
A thing about the selectors
The selectors we have here provide a huge value in deriving the data ready to be consumed by multiple parties.
This selector in the browseGames slice returns an object one with a tuple and with a modified string.
- The tuple is consumed by the UI.
- The modified string is sent in the search games request.
Derived data
Look at the below selector.
- It gets an input type of
state:RootState => AsyncGames
- Feeds the input into createSelector along with several other selectors including wishlist ids, in_cart ids, purchased ids.
- And we add the status into the list of games.
We use this selector with any list of games we have. As we need to show the wishlisted, inCart data in a lot of places in our application.
So, that is the underlying architecture.
Routes
Let's create our pages and feed them with the data required. We use a simple npm package that I published using the scripts I use to generate react components along with the corresponding storybook and test file.
npx rsb-gen pages/Home
npx rsb-gen pages/News
npx rsb-gen pages/Community
npx rsb-gen pages/CancelPayment
npx rsb-gen pages/ForgotPassword
npx rsb-gen pages/UserPage
npx rsb-gen pages/BrowseGames
npx rsb-gen pages/Library
npx rsb-gen pages/GamePage
npx rsb-gen pages/Checkout
npx rsb-gen pages/Signup
npx rsb-gen pages/Signin
npx rsb-gen pages/Wishlist
npx rsb-gen pages/NotFound
We know exactly what data each page needs. The below are the selectors the Home page uses.
<Router>
<main className="container mx-auto">
<div className="grid grid-cols-4 gap-1 mb-4">
<div className="col-span-4 my-2">Routes</div>
{Object.values(ROUTES).map((ROUTE) => (
<Link className="px-2 py-1 border rounded-sm" to={ROUTE} key={ROUTE}> {ROUTE}
</Link>))
}
</div>
<Switch>
<Route exact path={ROUTES.home}> <Home /> </Route>
{/* All routes here */}
<Route path="*"> <NotFound /> </Route>
</Switch>
</main>
</Router>
In the above code, we create all the routes within the switch component from react-router-dom. Also notice generate links to all the pages.
We will endup having like below.
Feed data to home page.
Lets feed all the pages we created above with the necessary data consuming the exported selectors with useAppSelector
.
const user = useAppSelector(selectUser)
const cart = useAppSelector(selectCartGames)
const wishlist = useAppSelector(selectWishlistGames)
const highestDiscoutsEver = useAppSelector(selectHighestDiscounts)
const wishlistIds = useAppSelector(selectWishlistGameIds)
const cartIds = useAppSelector(selectCartGameIds)
const actionGames = useAppSelector(selectActionGames)
const adventureGames = useAppSelector(selectAdventureGames)
const puzzleGames = useAppSelector(selectPuzzleGames)
const narrationGames = useAppSelector(selectNarrationGames)
Home component needs all the above selectors. If we want to add more or remove any selectors, it is childs play.
Now, run your application in localhost
and open that in the browser.
- Install React Developer Tools chrome extension and navigate to components tab.
- Select the component Home.
- You will find the props, hooks in the right side. The component Home does not have any props but it is listening to all those selectors using
useAppSelector
. - Expand the
Selector → DebugValue
to find the data.
Yai! The home component already has the data without creating any UI!
Feed ‘em all!
// BrowseGames.tsx
const { data: games } = useAppSelector(selectBrowseGamesWithWish)// Checkout.tsx
const { data: gamesInCart } = useAppSelector(selectCartGames)// Wishlist.tsx
const { data: wishlist } = useAppSelector(selectWishlistGames)
const { data: wishlistIds } = useAppSelector(selectWishlistGameIds)// Library.tsx
const {data: { uid }} = useAppSelector(selectUser)// Signin.tsx
const {data: { uid },loading,error} = useAppSelector(selectUser)
How cool is that? As promised the UI pages are plugging into our state effortlessly.
It won’t get too complicated from here. For example, look at the Gamepage.tsx. In addition to listening to the selectors, it does the below things.
- It gets the
id
param from the url. - Passes the
id
touseGetGamePage
hook which fetches the data.
// Gamepage.tsxconst gamePage = useAppSelector(selectGamePage)
const similarGames = useAppSelector(selectGamePageSimilarGames)const { id } = useParams<{ id: string }>()
useGetGamePage(id)
Once we get our infrastructure under our UI right, plugging UI components will become a breeze.
Thank you. See you next time.