Well, here we finally are: deep in the middle of the inevitable existential crisis that comes with building an app. We’ve built enough of the app that we have come to a point of realizing our existing architecture is not good enough.
Managing State
The current method of handling state in this app is ugly. It doesn’t have any real global state; most of the state is handled by the Home Screen component and data is passed from there to the other “screens” through React Navigation props.
The Problem
- There is a lot of copied code within different components which ultimately do the same thing – not DRY at all.
- Things like storing a reference to the Firebase logged-in user as well as the Realtime Database weren’t done at a high-enough level in the app, meaning that several components created their own references to the logged-in user and database.
- No global state – this makes it easy to screw up the handling of data between different components.
- It’s hard to maintain state and props between multiple screens and multiple tabs of React Navigation as each has their own state and props and passing props can be unpredictable and easy to get wrong.
- The functions responsible for changing data within the app end up mutating data too often.
The Solution
One word: Redux.
When I first started this project, the idea of adding Redux was already in the back of my mind. However, I was intimidated by the seemingly complex Redux library. Create a store? Write reducers? Create actions? Trigger dispatches? Ughhhhhh.
I believe the best way to learn something is to try to isolate it as much as possible down to its most fundamental pieces and build up from there. The problem with learning React is that the tutorials I found were in the context of creating a React app. It was thus adding a complex piece to an already complex puzzle.
You wouldn’t learn how to ice skate through playing ice hockey, would you?
Luckily, I stumbled upon a fantastic video course created by the Redux creator himself, Dan Abramov. What made it stand out was that the beginning of the course taught the fundamentals of Redux without any other libraries. Creating a working example of a Redux store all in plain JavaScript was what got me confident enough to know I could add it to my own app.
I created a small little JSFiddle to demonstrate the basics of creating a store, reducer, action, and dispatching that action. You can check it out here.
Back to the App
So, how exactly am I going to use Redux?
But First, a Principle
Redux is a very enlightened library and as such is guided by three core principles, the first of which is:
The state of your whole application is stored in an object tree within a single store.
aka a “single source of truth”
An object tree really just means that all of your global data is stored in one big object. The main state of your app should be centralized in a single location and not scattered around between various components.
The Store
I’ll create a single store for the app and wrap the root-level component within the store component. This gives everything in my app the ability to interact with the store(and for the store to interact with it).
This is the setup code:
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import contactsReducer from './reducers/contactsReducer'
const store = createStore(contactsReducer)
This is what my main App.tsx class returns(what gets rendered):
return (
<Provider store={store}>
<AppNavigator />
</Provider>
)
The <AppNavigator /> component is the root component of the app; it houses all of the React Navigation components the app uses.
That’s it for the root-level App.tsx component.
The Actions
Actions can best be described as the things that tell the app what happened. They are not responsible for determining what should change in response to what happens in the app. That’s the job of Reducers.
Specifically, Actions are plain JavaScript objects which deliver information from your application to your Redux store. They can also deliver a payload of data to your store and allow your reducer functions to handle that data to calculate a new state.
The Second Principle
The only way to change the state is to emit an action, an object describing what happened.
Actions abide by this principle by being the sole way we trigger app-level state changes. We never mutate state directly.
We will first create our Actions in a file that can be imported throughout the app and specify the types of actions our app will need to perform.
This is as simple as it gets to define an action:
export const getCachedContacts = () => ({
type: 'GET_CACHED_CONTACTS'
})
What’s going to happen here(there is obviously a bit more setup to do but I’ll spare the gory details) is our Reducer function is going to look for an action with the type GET_CACHED_CONTACTS and then act accordingly. Sometimes we’ll even send a payload of data. Imagine that we don’t just want to get something but we want to send something:
export const addNewContact = ( contact, userId ) => ({
type: 'ADD_NEW_CONTACT',
payload: { contact, userId }
})
Here, we are taking in a contact object(think, all of the details of a specific person) along with our Firebase userId(for authorization purposes) and putting them together into a single payload object.
This action would be called as a normal function within a component using the connect()
function and a custom mapDispatchToProps()
function.
Let’s see how the reducer handles these actions.
The Reducer
This line earlier from App.tsx is probably confusing so let’s explain what’s happening:
import contactsReducer from './reducers/contactsReducer'
This file is our single reducer for our app. The job of a reducer is to specify how state should react based on things that happen in the app. A Reducer doesn’t care about how it’s called. It responds to Actions, does something to calculate a new state(important: it doesn’t mutate anything) and then returns the new state. The Store then passes down that new state to the components in the app and makes sure stuff gets re-rendered where necessary.
The Last Principle
Changes are made with pure functions
It’s important to abide by this core principle when creating reducer functions. You do not ever mutate state; you calculate a new state and return it.
Example
This is a portion of the reducer I’m creating for the app(so ignore missing brackets and parentheses).
const contactsReducer = ( state=INITIAL_STATE, action ) => {
switch(action.type) {
case 'UPDATE_CONTACTS':
if ( !state || !state.contacts && !action.payload ) return INITIAL_STATE
if ( !action.payload ) return state
return Object.assign( {}, state, {
contacts: action.payload.contacts
} )
I have some conditional statements to make sure I’m covered in case something goes wrong in the app and the payload is falsy or state is somehow falsy.
The clever part is through the Object.assign()
method. What we’re doing here is creating a new object, passing in the existing state object, and then another object with a contacts property. If the state object already has a contacts property it will be overwritten.
In this way, you don’t modify the state object directly; you pass in a new object and overwrite existing properties with new information.
Test-Driven Development
Redux reducers need to utilize pure functions. Guess what makes it much easier to write pure functions? Test-Driven Development.
Write a test for what you expect to happen for each of your cases in the reducer function and then write the actual pure functions. You’ll uncover bugs before they happen and you’ll understand your code so much better than if you just plugged away without tests.
Conclusion
There’s obviously a bit more involved with adding Redux to cover the entire app but hopefully this sheds some light on the decision behind using Redux as well as the fundamental ideas behind how it actually functions.