Real-time app using React, Redux, Tailwind CSS & Firebase - Part 2
Table of contents
NOTE: This series goes a bit unconventional, by not using any libraries or abstractions for Redux, following plain Flux architecture conventions. The recommended way of writing Redux is by using RTK (Redux Toolkit).
In the previous part we laid out the requirements, planned the architecture and initialized firebase. Now, we are going to setup Redux, connect it to Firebase and create our first component.
Setting up Redux
Why vanilla redux
As stated in the previous part, we are going to use Redux Core and not Redux Toolkit, which includes Redux Core, plus a few other packages and abstractions to make development easier. From the official page:
The Redux Toolkit package is intended to be the standard way to write Redux logic. It was originally created to help address three common concerns about Redux:
- "Configuring a Redux store is too complicated"
- "I have to add a lot of packages to get Redux to do anything useful"
- "Redux requires too much boilerplate code"
The reason we are not going to use Redux Toolkit, or any other similar package is simply because getting to know the intricacies of the tools you use to build important parts of your application - and state management is one of them - is of paramount importance. I'm not talking about learning the internals of webpack here, but knowing how to setup and develop a vanilla Redux project before using various abstractions and templates, IMHO, is a must. Furthermore, you won't be able to understand the core Redux concepts (or Flux architecture, in general) without getting your hands dirty at a "lower level".
Configuring the store
In this series I won't be explaining how Redux works, only providing brief insights and links to any resource I deem useful. If you want to take a deep dive into Redux you will find everything you need in the official page.
Root reducer
The first thing we are going to do is create the root reducer. The root reducer is going to combine all of our reducers inside src/store/reducers
. This gives us the ability to namespace our state, by creating different slices of it and separate business logic. As stated in the official FAQ section:
The suggested structure for a Redux store is to split the state object into multiple “slices” or “domains” by key, and provide a separate reducer function to manage each individual data slice. This is similar to how the standard Flux pattern has multiple independent stores, and Redux provides the
combineReducers
utility function to make this pattern easier.
You can read more about splitting up reducers logic and combineReducers
here and here.
Create a file named index.js
inside src/store/reducers
and type the following code:
import { combineReducers } from "redux";import feature from "./feature";export default combineReducers({feature});
Also, create a file named feature.js
in the same folder to avoid getting an import error. This is going to be our FeatureTitle
component reducer, but just leave it empty for now and ignore the console complaining about not having a valid reducer.
Application root file
The root file of our app, index.js
, is going to contain all of the "binding" logic (Provider
components) both for Redux and Firebase. It should now look like this:
import React from "react";import ReactDOM from "react-dom";import "./index.css";import App from "./containers/App";import rootReducer from "./store/reducers/index";import { createStore } from "redux";import { Provider } from "react-redux";const store = createStore(rootReducer);ReactDOM.render(<React.StrictMode><Provider store={store}><App /></Provider></React.StrictMode>,document.getElementById("root"));
In the above snippet, we basically pass the root reducer to the createStore
method in order to create our store. After that, we pass it as a prop to the Provider
component, which is going to wrap the App
component and make our React app aware of the store.
App component
Now we should be able to use redux inside our app. Inside src/containers/App.js
import some Redux hooks to make sure that everything is running smoothly. It should look like this:
import logo from "../logo.svg";import "./App.css";// Import these two hooks from Reduximport { useDispatch, useSelector } from "react-redux";function App() {// Create a dispatcherconst dispatch = useDispatch();return (<div className="App"><header className="App-header"><img src={logo} className="App-logo" alt="logo" /><p>Edit <code>src/App.js</code> and save to reload.</p><aclassName="App-link"href="https://reactjs.org"target="_blank"rel="noopener noreferrer">Learn React</a></header></div>);}export default App;
At this point, running npm start
to start the development server - if you haven't already -should not produce any error. Next, we are going to install redux-devtools
in order to be able to access and debug our state client-side.
Installing Redux devtools
You can basically follow the official instructions, but we'll cover it here, since it's fairly quick. Run:
npm install --save-dev redux-devtools
Then add this argument to the createStore
method inside src/index.js
:
window.REDUX_DEVTOOLS_EXTENSION && window.REDUX_DEVTOOLS_EXTENSION()
It should now look like this:
const store = createStore(rootReducer,window.REDUX_DEVTOOLS_EXTENSION && window.REDUX_DEVTOOLS_EXTENSION() // Add this);
Finally install the chrome extension from the chrome web store. If you are not using chrome or encounter any other issue, please visit the official extension page.
Close and re-open chrome devtools and refresh the page. You should be able to see a tab named Redux. This is where redux devtools live.
NOTE: Later on, we are going to change the way we initialize devtools, because we are going to use store enhancers and middleware.
Creating FeatureTitle component
Now that we've set up Redux we are ready to create our first component! We will begin by designing a generic Input component, then move on to crafting its state and finally add Firebase persistence. By taking a look at our component diagram from the previous part, we can clearly see that FeatureTitle
and UserName
are simple input
components with their functionality doubling as data input and data display. A generic Input
component is going to be used to facilitate the creation of FeatureTitle
and UserName
components.
Designing a generic Input component
Inside src/component
create a folder named Input
and add a file named index.js
. Then paste the following code:
import React from "react";import PropTypes from "prop-types";const Input = props => {const label = props.label ? (<labelhtmlFor={props.name}className="block text-sm font-medium text-gray-700">{props.label}</label>) : null;return (<React.Fragment>{label}<inputtype="text"name={props.name}className={props.className}placeholder={props.placeholder}onChange={props.handleChange}value={props.value}disabled={props.disabled}/></React.Fragment>);};// Not required, but highly recommendedInput.propTypes = {label: PropTypes.string.isRequired,name: PropTypes.string.isRequired,placeholder: PropTypes.string,onChange: PropTypes.func,value: PropTypes.string.isRequired,disabled: PropTypes.bool.isRequired};export default Input;
We created a generic, fairly flexible Input
component with dynamic styling, placeholder, etc., to use throughout our app as we see fit.
NOTE: Using propTypes
is not necessary, but is highly recommended, especially when not using any other form of type-checking, such as Typescript. Type-checking can help catch bugs, as well as document our code. In this project, we are going to use them, so if you are not going to omit them run npm i prop-types
to install the relevant package.
Designing FeatureTitle component
Go ahead and create a folder named FeatureTitle
in src/components
. Add a file named index.js
and paste the component code:
import Input from "../Input";import { useDispatch, useSelector } from "react-redux";import setTitle from "../../store/actions/feature/setTitle";const FeatureTitle = () => {const title = useSelector(state => state.feature.title);const dispatch = useDispatch();const handleTitleChange = event => {dispatch(setTitle(event.target.value));};return (<div className="mt-10"><InputclassName="items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"handleChange={handleTitleChange}// Display loading message while title has a value of nullvalue={title === null ? "Loading title..." : title}placeholder="Feature title"disabled={title === null ? true : false}label="Feature Title"name="title"/></div>);};export default FeatureTitle;
I hope that the code is mostly self-explaining. We basically grab the current title from the central store using useSelector
hook (like useState
, but for Redux) and assign value
and disabled
props based on its value. We also create a dispatcher to handle the onChange
event, by dispatching the SET_TITLE
action along with its payload (the new value).
Crafting the state
Constants
Constants help reduce typos and keep our code more organized. As stated here:
It is often claimed that constants are unnecessary, and for small projects, this might be correct. For larger projects, there are some benefits to defining action types as constants:
- It helps keep the naming consistent because all action types are gathered in a single place.
- Sometimes you want to see all existing actions before working on a new feature. It may be that the action you need was already added by somebody on the team, but you didn't know.
- The list of action types that were added, removed, and changed in a Pull Request helps everyone on the team keep track of scope and implementation of new features.
- If you make a typo when importing an action constant, you will get
undefined
. Redux will immediately throw when dispatching such an action, and you'll find the mistake sooner.
Inside src/store/constants
create a file named feature.js
and type the following code:
export const SET_TITLE = "SET_TITLE";
Here we are simply exporting a constant named SET_TITLE
which is going to be used as an action name to change our component's title.
Actions
Inside src/store/actions
create a folder named feature
. Any action associated with the FeatureTitle
component will be placed in there. Add a file named setTitle.js
and paste the following code:
import { SET_TITLE } from "../../constants/feature";const setTitle = payload => dispatch => {dispatch({type: SET_TITLE,payload});};export default setTitle;
This action is solely responsible for setting the FeatureTitle
value in our Redux store.
Reducer
Inside the feature.js
file we created earlier in src/store/reducers
, paste the following code:
import * as actionTypes from "../constants/feature";// The initial state objectconst initState = {title: null};const featureReducer = (state = initState, action) => {switch (action.type) {case actionTypes.SET_TITLE: {// Return new state objectreturn {title: action.payload};}default:return state;}};export default featureReducer;
As you can see, the reducer is just a function which receives the current state
and the action
to be performed as arguments and calculates the new state derived from that action.
Adding Firebase persistence
The final step for a working component is adding persistence to our database. To accomplish this, we first need to wrap our app with the Firebase Provider component.
Connect Firebase with application
Head over to src/index.js
and add the following imports:
import thunk from "redux-thunk";// Get internal Firebase instance with methods which are wrapped with action dispatches.import { getFirebase } from "react-redux-firebase";// React Context provider for Firebase instanceimport { ReactReduxFirebaseProvider } from "react-redux-firebase";// Firebase configurationimport config from "./config/firebase";// Firebase SDK libraryimport firebase from "firebase/app";
Also, modify the redux imports to include applyMiddleware
and compose
methods:
import { applyMiddleware, createStore, compose } from "redux";
We also need to change the way we initialize devtools:
// Use devtools compose method if defined, else use the imported one from Reduxconst composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;// This will make getFirebase method available to our thunksconst middlewares = [thunk.withExtraArgument(getFirebase)];
and refactor the store to include the new middleware:
const store = createStore(rootReducer,composeEnhancers(applyMiddleware(...middlewares)));
Then, wrap the App
component with ReactReduxFirebaseProvider
like this:
ReactDOM.render(<React.StrictMode><Provider store={store}><ReactReduxFirebaseProviderfirebase={firebase} // Firebase libraryconfig={config} // react-redux-firebase configdispatch={store.dispatch} // Redux's dispatch function><App /></ReactReduxFirebaseProvider></Provider></React.StrictMode>,document.getElementById("root"));
The end result should be this:
import React from "react";import ReactDOM from "react-dom";import "./index.css";import App from "./containers/App";// Redux importsimport rootReducer from "./store/reducers/index";import { applyMiddleware, createStore, compose } from "redux";import { Provider } from "react-redux";import thunk from "redux-thunk";// Firebase importsimport { getFirebase } from "react-redux-firebase";import { ReactReduxFirebaseProvider } from "react-redux-firebase";import config from "./config/firebase";import firebase from "firebase/app";const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;const middlewares = [thunk.withExtraArgument(getFirebase)];const store = createStore(rootReducer,composeEnhancers(applyMiddleware(...middlewares)));ReactDOM.render(<React.StrictMode><Provider store={store}><ReactReduxFirebaseProviderfirebase={firebase}config={config}dispatch={store.dispatch}><App /></ReactReduxFirebaseProvider></Provider></React.StrictMode>,document.getElementById("root"));
In case you face any issues, the official documentation for react-redux-firebase is here.
Some tips:
Middleware vs Store Enhancers
In short:
Middleware adds extra functionality to the Redux
dispatch
function; enhancers add extra functionality to the Redux store.
You can read more about extending Redux functionality here.
Compose method
The compose
method is a utility function often seen in functional programming. As stated here:
You might want to use it to apply several store enhancers in a row.
ApplyMiddleware method
The official description of the applyMiddleware
method:
Middleware is the suggested way to extend Redux with custom functionality. Middleware lets you wrap the store's
dispatch
method for fun and profit. The key feature of middleware is that it is composable. Multiple middleware can be combined together, where each middleware requires no knowledge of what comes before or after it in the chain.
It applies the given middleware and returns a store enhancer.
Redux Thunk
Redux Thunk is a middleware which allows us to create actions that return a function instead of an action object. This function, when called, returns the action object instead which in turn gets passed as an argument to the dispatcher.
Connect Firebase with component
Now that we integrated Firebase with Redux and connected everything to our App component, we can manipulate data saved in Firebase from anywhere, through our Redux store!
Debounce function
First create a file named debounce.js
inside src/utils
and paste the following code:
export default function debounce(func, wait, immediate) {var timeout;return function () {var context = this,args = arguments;clearTimeout(timeout);timeout = setTimeout(function () {timeout = null;if (!immediate) func.apply(context, args);}, wait);if (immediate && !timeout) func.apply(context, args);};}
This is going to be used on inputs and buttons, to prevent aspiring spammers from flooding our database with requests 😏.
Push updates to Firebase
Inside src/firebase
create a folder named feature
. This folder is going to contain all Feature related firebase functionality/services. Add a file named updateTitle.js
and paste the following code:
import debounce from "../../utils/debounce";import { SET_TITLE } from "../../store/constants/feature";const updateTitle = ({ ref, payload, oldState, firebase, dispatch }) => {firebase.ref(ref) // Find reference to update.set(payload) // Set new value.then(error => {// Revert to old state in case of errorif (error) {dispatch({type: SET_TITLE,payload: oldState});alert("There was an error performing the request.");}});};export default debounce(updateTitle, 500);
This function is going to be used to update the FeatureTitle
value in the firebase database. You can check the official Firebase Javascript SDK docs here.
Receive updates from Firebase
Add another action named setupFirebaseListeners.js
in src/store/actions/feature
and paste the following code:
import { SET_TITLE } from "../../constants/feature";const setupFeatureListeners = () => (dispatch, getState, getFirebase) => {const firebase = getFirebase();// Get feature firebase referenceconst featureRef = firebase.database().ref("feature");/* Title loading and updates handling */featureRef.on("value", snapshot => {dispatch({type: SET_TITLE,payload: snapshot.val().title // New value});});};export default setupFeatureListeners;
This action, once dispatched, will register an event handler for every change in FeatureTitle
value update. This event handler will essentially dispatch the SET_TITLE
action, in order to update the application state. It will be executed on initial application load, as well as every time the title value changes (by another client, because changes made from us are immediately reflected in the UI for performance reasons, as stated below).
This sums up the two-way binding between our Redux state and Firebase, providing the app with real-time updates.
Head over to src/store/actions/feature/setTitle.js
action file and modify it to push updates to Firebase:
import { SET_TITLE } from "../../constants/feature";// This will handle logic relevant ONLY to firebase update, not Redux stateimport firebaseUpdateTitle from "../../../firebase/feature/updateTitle";const setTitle = payload => (dispatch, getState, getFirebase) => {const firebase = getFirebase();const state = getState();// Getting old titleconst {feature: { title: oldState }} = state;const config = {ref: "feature/title", // Path in firebase to updatepayload, // Payload valueoldState, // Old state objectfirebase, // Firebase instancedispatch // Redux dispatch function};// Update state and firebase independentlyfirebaseUpdateTitle(config);// Dispatch asynchronously to maintain a responsive UIdispatch({type: SET_TITLE,payload});};export default setTitle;
NOTE: The key thing to notice here is that we are calling the Firebase middleware function independently of Redux state update (dispatch). This effectively decouples the UI state from the Firebase state. This is important, because if we updated the state after the Firebase promise resolution (either success or failure) then the UI would be unresponsive and laggy. This way, we immediately update the application state, assuming changes were successful and revert to the old one, in case something goes wrong. That's why we pass oldState
to firebaseUpdateTitle
.
Finally, inside App
component import FeatureTitle
, initialize main layout and register Feature event handlers. Replace the code inside src/containers/App.js
with the following:
import "./App.css";import FeatureTitle from "../components/FeatureTitle";import { useDispatch, useSelector } from "react-redux";import { useEffect } from "react";import setupFeatureListeners from "../store/actions/feature/setupFirebaseListeners";function App() {const dispatch = useDispatch();// Setting up feature listenersuseEffect(() => {dispatch(setupFeatureListeners());}, []);return (<main className="max-w-7xl mx-auto my-5 px-4 sm:px-6 lg:px-8"><div className="flex flex-col flex-wrap max-w-3xl mx-auto mt-10"><div className="flex justify-center"><FeatureTitle /></div></div></main>);}export default App;
Go to localhost:3000
and you should be able see our component in the center of the page. Open a second tab/browser and try changing the input value. Changes should be synchronized between tabs/windows after the specified debounce
timeout (500 ms in this case).
That's it for this part, hope it wasn't tedious. Let me know if you found it interesting.
Any other feedback is also appreciated! Stay tuned for part 3 😎