React Project Outgrowing Expectations? Learn These Basic Principles to Manage Better

React Project Outgrowing Expectations? Learn These Basic Principles to Manage Better

Consequences of Poor Code Maintenance

Imagine building a product with great potential, but watching it progress slowly due to messy code and disorganized thinking.

We are a small team, building a product called Hexmos Feedback, Feedback helps keep teams motivated and engaged through recognition and continuous feedback.

We have attendance management as a part of Hexmos Feedback.

Hexmos Feedback goes beyond simple attendance – it helps identify committed employees who are present but may not be fully invested in the organization's goals.

We opted for React, known for its ease of use and scalability, as the foundation for our product's front end.

We were focused on getting features out the door, screens looking good, and functionality working. In that initial sprint, coding standards weren't exactly a priority.

Looking back, we just threw components wherever they fit, integrated APIs directly into component files (except for that base URLs!), and cobbled together structures that barely held things together.

Again, it wasn't the worst approach for a small team focused on a bare-bones MVP.

Growing Pains: The Need for Scalability

But then, things changed. As our capabilities matured a little bit, we started training students from various colleges, and we brought on some committed interns to help us scale our team.

That's when the cracks began to show. Our once "functional" codebase became difficult for these newbies to navigate.

Simple bug fixes turned into hour-long hunts just to find the relevant code.

It was a wake-up call. Our codebase was hindering progress and killing developer morale. That's when I knew we had to make a change.

Learning From Our Mistakes: Embracing Best Practices

It was time to learn some best practices, clean up the mess, and build a codebase that was scalable, maintainable, and wouldn't make our interns tear their hair out.

Organizing for Scalability

It took me more than two hours to organize the components, fix the import errors, and things got slightly better.

I understood the importance of a well-structured codebase for easy scalability and maintenance.

Most of the code should be organized within feature folders.

markmap of folder structure

Each feature folder should contain code specific to that feature, keeping things neatly separated.

react code structure

This approach helped prevent mixing feature-related code with shared components, making it simpler to manage and maintain the codebase compared to having many files in a flat folder structure.

How Redux Simplified API Integration

Remember that initial focus on getting features out the door? While it worked for our simple MVP, things got hairy as we added more features.

Our initial approach of making API calls directly from component files became a burden.

As the project grew, managing loaders, errors, and unnecessary API calls became a major headache.

Features were getting more complicated, and our codebase was becoming a tangled mess.

Component file lines increased, business logic (API calls, data transformation) and UI logic (displaying components, handling user interactions) got tangled, and maintaining the codebase and complexity to handle loaders, errors, etc became a nightmare.

As our project matured and the challenges of managing the state grew, I knew it was time to implement a better approach.

I started using Redux for API calls, a state management library for JavaScript applications, along with thunks (like createSlice, and createAsyncThunk from @reduxjs/toolkit) to handle this problem.

This is a strong approach for complex applications as Redux acts as a central hub for your application's state, promoting separation of concerns and scalability.

Here's why Redux was a game-changer

1. Centralized State Management

Redux keeps all your application's state in one central location. This makes it easier to track changes and manage loading and error states, in any component. No more hunting through a maze of component files!

2. Separation of Concerns

Thunks and slices (Redux's way of organizing reducers and actions) allow you to cleanly separate your business logic (API calls, data transformation) from your UI logic (displaying components, handling user interactions). This makes the code more readable, maintainable, and easier to test.

3. Consistency

Redux enforces a consistent way to handle API calls and manage state. This leads to cleaner, more maintainable code across your entire project.

4. Caching for Efficiency

Redux can help with caching and memoization of data, reducing unnecessary API calls and improving performance.

We'll dive into a concrete example in the next section to show you how Redux and thunks can be implemented to tame the API beast in your own React projects.

How to reduce burden using Redux

Here is how I got started with the conversion.
Considering you have already installed reduxjs/toolkit.

The Below image is how I have organized the files for redux in my streak feature.
image

Async Actions with Redux Thunk (actionCreator.ts)

First, create a file actionCreator.js and add a thunk for the API call of your component.

This file defines an asynchronous thunk action creator named fetchIndividualStreak using createAsyncThunk from @reduxjs/toolkit.

Thunks are middleware functions that allow us to perform asynchronous operations (like API calls) within Redux actions.

It handles both successful and unsuccessful responses:

  • On success, it returns the response data.
  • On failure, it rejects the promise with an error message.
// actionCreator.ts
import { createAsyncThunk } from "@reduxjs/toolkit";

export const fetchIndividualStreak = createAsyncThunk(
  "streak/fetchIndividualStreak",
  async (memberId, { rejectWithValue }) => {
    try {
      const data = {
        member_id: memberId,
      };
      const response = await apiWrapper({
        url: `${urls.FB_BACKEND}/v3/member/streak`,
        method: "POST",
        body: JSON.stringify(data),
      });
      return response;
    } catch (error) {
      return rejectWithValue(error.message);
    }
  }
);

Centralized State (reducer.ts)

This file defines a slice using createSlice from @reduxjs/toolkit.

A slice groups related reducers and an initial state for a specific feature (streak in this case).

The initial state includes properties for:

  • loading: indicates if data is being fetched
  • data: holds the fetched streak information
  • error: stores any errors encountered during the API call
// reducer.ts
import { createSlice } from "@reduxjs/toolkit";
import { fetchIndividualStreak } from "./actionCreator";

const streakSlice = createSlice({
  name: "streak",
  initialState: {
    loading: false,
    data: null,
    error: null,
  },
  reducers: ...,
  extraReducers: ...,
});

State Flow During Execution
Initial Render: Before API stats executing

When the component mounts, the streak slice's initial state is used.
This means loading is initially false, data is null, and error is null.
The UI won't display a loader or any streak information at this point.

Fetching Data: Triggering API fetching

The UI component dispatches the fetchIndividualStreak action using useDispatch.
This triggers the asynchronous thunk functionality within the action creator.

Pending State: API is already triggered

While the API call is in progress, the fetchIndividualStreak.pending case in the reducer is triggered.
This sets the loading state to true and clears any existing error.
The UI can now display a loader to indicate that data is being fetched.

// reducer.ts
const streakSlice = createSlice({
  extraReducers: (builder) => {
    /*----------- Handle Pending State ------------------------*/
    builder.addCase(fetchIndividualStreak.pending, (state) => {
      state.loading = true;
      state.error = null;
    });
  },
});
Success (Fulfilled State): API success

If the API call is successful, the fetchIndividualStreak.fulfilled case is triggered.
This sets the loading state back to false and update the data state with the fetched streak information.
The component's useEffect hook that monitors the data state will then trigger a re-render, and the UI can use the updated data to display the necessary information.

// reducer.ts
const streakSlice = createSlice({
  extraReducers: (builder) => {
    /*----------- Handle Fullfiled State ------------------------*/
    builder.addCase(fetchIndividualStreak.fulfilled, (state, action) => {
      state.loading = false;
      state.data = action.payload;
    });
  },
});
Failure (Rejected State): API failed

If the API call fails, the fetchIndividualStreak.rejected case is triggered.
This sets the loading state back to false and update the error state with the error message from the API.
The UI can conditionally render an error message based on the presence of an error in the state.

// reducer.ts
const streakSlice = createSlice({
  name: "streak",
  initialState: ...initialState,
  reducers: {
    setStreakPackage: (state, action) => {
      state.data = action.payload;
    },
  },
  extraReducers: (builder) => {
      /*----------- Handle Pending State -------------------------*/
      builder.addCase(fetchIndividualStreak.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      /*----------- Handle Fullfiled State ------------------------*/
      builder.addCase(fetchIndividualStreak.fulfilled, (state, action) => {
        state.loading = false;
        state.data = action.payload;
      })
      /*----------- Handle Rejected State ------------------------*/
      builder.addCase(fetchIndividualStreak.rejected, (state, action) => {
        state.loading = false;
        state.error = action.payload
      });
  },
});

export const { setStreakPackage } = streakSlice.actions;

export default streakSlice.reducer;

By managing the state transitions within Redux reducers, we can efficiently communicate loading states, errors, and successful data retrieval to the React components, enabling them to update the UI accordingly. This centralized approach to state management and API interactions leads to a more responsive and user-friendly experience.

Connecting the Dots in the Component (IndividualStreak.tsx)

This component displays the user's streak information on a calendar.
It uses useDispatch from React-Redux to dispatch actions and useSelector to access the Redux state.

The initial useEffect hook triggers the fetchIndividualStreak action to retrieve streak data for the specified member ID.

// IndividualStreak.tsx
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchIndividualStreak } from "./redux/actionCreator";
import { setStreakPackage } from "stores/slices";

function IndividualStreak({ memberId }) {
  const dispatch = useDispatch();
  const { loading, data: streakData, error } = useSelector((state: any) => state.streak);

  useEffect(() => {
    dispatch(fetchIndividualStreak(memberId));
  }, [memberId, dispatch]);

A second useEffect hook conditionally dispatches the setStreakPackage reducer only if data is successfully fetched and has feedback information.

useEffect(() => {
  if (
    streakData &&
    streakData.feedback_data &&
    streakData.feedback_data.length > 0
  ) {
    dispatch(setStreakPackage(streakData.feedback_data));
  }
}, [streakData, dispatch]);

The component conditionally renders a loading bar, error message, or calendar component based on the state (loading, error, and streakData).

// IndividualStreak.tsx
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import moment from "moment";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import CalendarWrap from "../CalendarWrap";
import { fetchIndividualStreak } from "./redux/actionCreator";
import { setStreakPackage } from "stores/slices";
import Alert from "@mui/material/Alert";

function IndividualStreak({ memberId }) {
  const dispatch = useDispatch();
  const {
    loading,
    data: streakData,
    error,
  } = useSelector((state: any) => state.streak);

  useEffect(() => {
    dispatch(fetchIndividualStreak(memberId));
  }, [memberId, dispatch]);

  useEffect(() => {
    if (
      streakData &&
      streakData.feedback_data &&
      streakData.feedback_data.length > 0
    ) {
      dispatch(setStreakPackage(streakData.feedback_data));
    }
  }, [streakData, dispatch]);

  return (
    <div>
      {loading && (
        <Box sx={{ width: "100%" }}>
          <LinearProgress />
        </Box>
      )}{" "}
      {error && <Alert severity="error">{error}</Alert>}
      {streakData && (
        <div style={{ width: "50%" }}>
          <CalendarWrap
            startDate={moment().subtract(12, "month")}
            endDate={moment()}
            streakData={streakData.feedback_data}
          />
        </div>
      )}
    </div>
  );
}

export default IndividualStreak;

This simplified example showcases the power of Redux for state management and API interactions in React applications.

Centralizing state and handling API calls within Redux leads to several benefits:

Improved Separation of Concerns

Business logic (API calls, data transformation) is separated from UI logic (displaying components, handling user interactions). This makes code cleaner and easier to maintain.

Enhanced Maintainability

Redux actions and reducers are easier to test and manage compared to components with embedded API logic.

Scalability

The Redux approach scales well for handling complex features like pagination, searching, and filtering, as these functionalities can leverage centralized state management.

By offloading the responsibility of managing loaders, errors, and API success/failure from components to Redux, we achieve a cleaner and more maintainable codebase. This approach lays the foundation for building robust and scalable React applications.

FeedZap: Read 2X Books This Year

FeedZap helps you consume your books through a healthy, snackable feed, so that you can read more with less time, effort and energy.

The Final Touches

As we wrapped our heads around Redux and implemented a solid code structure, we knew there was still room for improvement.

One area that constantly caused friction during code reviews was formatting inconsistencies.

Interns had different preferences for code formatters and styling configurations, leading to a sea of unnecessary changes cluttering the review process.

This is where pre-commit hooks entered the scene. We implemented Husky, a popular tool that allows us to run scripts before a commit is made.

This ensures that everyone's code adheres to the same style guidelines, reducing noise in code reviews and preventing potential bugs that might arise from inconsistent formatting.

Want to see a deeper dive into implementing these practices with code examples? Check out the full implementation details in the following sections of Auto-Formatting with Prettier, Husky & lint-staged on Every Git Commit blog post.

Conclusion

By embracing these practices, we've transformed our codebase from a tangled mess into a well-structured, maintainable, and scalable project.

This not only makes our lives easier but also ensures a smoother experience for our interns and future developers who join the team.

This concludes our exploration of the challenges and solutions we encountered while building our product.

I hope this narrative serves as a roadmap for those embarking on similar endeavors, helping you avoid the pitfalls we faced and build robust, maintainable applications.

Stay ahead of the curve! Subscribe for a weekly dose of insights on development, IT, operations, design, leadership and more.