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.

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

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

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

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 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.

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.

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

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.

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.

action without PayloadAction type

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.

action with PayloadAction type

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.

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.

Let's look at the type and the state of this slice.

GameSlice State

[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.

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.

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 as Game | 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)

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.

type of getGamePage action

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 type GameGenre
  • Output: { games } of type Game[]

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]

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

  1. We fetch the game details.
  2. We re-fetch game details when the gameId changes. (The dependency array has [gameId])
  3. 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?

browseGames slice shuttling the filter state

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,

  1. Get all the data including the filters, searchTerm, and search results, and render accordingly.
  2. Simply turn on the hook useAlgoliaSearchGames() in the component. It hits algolia whenever any dependent selector changes.
  3. 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.

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.

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.

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!

// 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 to useGetGamePage 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.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Karthick Ragavendran

Fullstack engineer @ atem.green | React, Typescript, Redux, JavaScript, UI, Storybook, CSS, UX, Cypress, CI/CD.