Back to all posts

What I Stopped Doing in React Projects (and Why My Code Got Better)

I spent months building a production React platform that manages campaigns, customer conversations, and permissions across multiple organizations. Somewhere around the third month, I realized the code wasn't getting worse because I was doing too little — it was getting worse because I was doing too much.

Writing maintainable React code isn't about following best practices. It's about knowing which practices to stop following.

Here are five things I eliminated, and the measurable improvements that followed.


1. I Stopped Using Redux for Server State

Before: 200 Lines of Boilerplate

Every time I needed to fetch data from an API, I wrote code like this:

// 3 action types, 1 action creator, 1 reducer, 2 selectors...
const FETCH_CAMPAIGNS_REQUEST = "FETCH_CAMPAIGNS_REQUEST";
const FETCH_CAMPAIGNS_SUCCESS = "FETCH_CAMPAIGNS_SUCCESS";
const FETCH_CAMPAIGNS_FAILURE = "FETCH_CAMPAIGNS_FAILURE";
 
export const fetchCampaigns = (page) => async (dispatch) => {
  dispatch({ type: FETCH_CAMPAIGNS_REQUEST });
  try {
    const data = await api.get(`/campaigns?page=${page}`);
    dispatch({ type: FETCH_CAMPAIGNS_SUCCESS, payload: data });
  } catch (error) {
    dispatch({ type: FETCH_CAMPAIGNS_FAILURE, error });
  }
};
 
// ...plus a reducer with 3 cases, selectors, and finally the component:
function Campaigns() {
  const dispatch = useDispatch();
  const campaigns = useSelector(selectCampaigns);
  const loading = useSelector(selectLoading);
 
  useEffect(() => {
    dispatch(fetchCampaigns(page));
  }, [page]);
 
  // Now you can render something.
}

Action types, action creators, a reducer, selectors, a component wiring it all together — 200+ lines just to display a list.

After: 15 Lines with SWR

// Hook
export function useCampaigns(page = 1, limit = 10) {
  const { isAuthenticated } = useAuth();
  const key = isAuthenticated ? ["/campaigns", page, limit] : null;
 
  const { data, error, mutate } = useSWR(key, () =>
    campaignService.getCampaigns(page, limit)
  );
 
  return {
    campaigns: data?.info ?? [],
    isLoading: !error && !data,
    error,
    mutate,
  };
}
 
// Component
function Campaigns() {
  const { campaigns, isLoading } = useCampaigns(page);
  // That's it. Just render.
}

Why This Works

Redux solves a problem I don't have. My application doesn't need:

  • Time-travel debugging
  • Undo/redo across complex workflows
  • Shared state between 50+ disconnected components

What I do need:

  • Automatic caching (SWR handles this)
  • Background revalidation (built-in)
  • Deduplication (two components fetch same data → one request)

Result: 85% less code, zero manual cache invalidation.

When I'd Reconsider

If I built a collaborative editor with operational transforms or a complex state machine, Redux would make sense. But for fetching campaigns and displaying them? SWR wins.


2. I Stopped Implementing Client-Side Token Refresh

Before: 300 Lines of Race Condition Hell

The typical client-side token refresh involves an interceptor that catches 401 responses, queues concurrent requests, refreshes the token, and replays everything. In practice, this looked like:

let isRefreshing = false;
let failedQueue = [];
 
apiClient.interceptors.response.use(null, async (error) => {
  if (error.response?.status === 401 && !originalRequest._retry) {
    if (isRefreshing) {
      // Queue this request until refresh completes
      return new Promise((resolve, reject) => {
        failedQueue.push({ resolve, reject });
      });
    }
    isRefreshing = true;
    // ...refresh token, replay queue, handle errors, clear state
  }
});

Even this abbreviated version hints at the complexity. The full implementation was 300+ lines. Problems I encountered:

  • Concurrent requests triggering multiple refresh attempts
  • Refresh endpoint returning 401 → infinite loop
  • Queue state not clearing on logout
  • Refresh tokens exposed in browser memory (XSS risk)

After: Backend Handles It, Client Logs Out

apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      AuthService.clearAuthData();
      window.location.href = "/login";
    }
    return Promise.reject(error);
  }
);

10 lines. Zero race conditions.

Why This Works

Security: Refresh tokens live in HTTP-only cookies (JavaScript can't access them). XSS attacks can't steal what they can't see.

Simplicity: Backend handles refresh complexity. Client has one job: logout on 401.

The Tradeoff

Users get logged out after ~15 minutes of inactivity instead of staying logged in forever.

Is this acceptable? For my marketing platform, yes. Users check campaigns once a day, spend < 10 minutes per session. The 15-minute token expiry rarely affects them.

Would I reconsider? If I built a real-time trading platform or collaborative editor where users stay logged in for hours, I'd implement client-side refresh. But I'd do it knowing the complexity cost.


3. I Stopped Checking user.role === 'admin'

Before: Role Checks Everywhere

function CampaignList() {
  const { user } = useAuth();
  const isAdmin = user.role === "admin";
  const isManager = user.role === "manager";
 
  return (
    <div>
      {(isAdmin || isManager) && <Button>Create Campaign</Button>}
      {isAdmin && <Button>Delete Campaign</Button>}
    </div>
  );
}

What happened when we added a "supervisor" role?

Updated 40+ components. Every single place that checked user.role.

After: Resource-Action Permissions

function CampaignList() {
  const { hasPermission } = useAuth();
 
  const canCreate = hasPermission("campaigns", "create");
  const canDelete = hasPermission("campaigns", "delete");
 
  return (
    <div>
      {canCreate && <Button>Create Campaign</Button>}
      {canDelete && <Button>Delete Campaign</Button>}
    </div>
  );
}

What happened when we added a "supervisor" role?

Zero client code changes. Backend updated permissions, client adapted automatically.

Why This Works

Permissions are data, not logic. Backend defines what each role can do:

{
  "role": "manager",
  "permissions": {
    "campaigns": { "view": true, "create": true, "delete": false },
    "agents": { "view": true, "create": false, "delete": false }
  }
}

Client just consumes this. No hardcoded role checks.

Critical Security Note


4. I Stopped Writing Snapshot Tests

Before: Tests That Break on CSS Changes

it("renders campaign card", () => {
  const tree = renderer.create(<CampaignCard campaign={mock} />).toJSON();
  expect(tree).toMatchSnapshot();
});

What breaks this test?

  • Changed <div> to <article>
  • Renamed CSS class
  • Adjusted padding
  • Added a wrapper for layout

What doesn't break this test?

  • Delete button doesn't call the delete service
  • Form submits with wrong data
  • Permission check is removed

Snapshot tests fail on implementation changes, not behavior changes.

After: Integration Tests That Prove Behavior

it("creates campaign when form is valid", async () => {
  const createSpy = vi
    .spyOn(campaignService, "createCampaign")
    .mockResolvedValue({ status: "OK" });
 
  render(<CreateCampaignDialog open={true} />);
 
  await userEvent.type(screen.getByLabelText(/name/i), "Welcome Campaign");
  await userEvent.click(screen.getByRole("button", { name: /create/i }));
 
  await waitFor(() => {
    expect(createSpy).toHaveBeenCalledWith(
      expect.objectContaining({ name: "Welcome Campaign" })
    );
  });
});

What breaks this test?

  • Form doesn't call the service
  • Service called with wrong data
  • User can't submit (button disabled incorrectly)

What doesn't break this test?

  • Changed <div> to <article>
  • Renamed CSS classes
  • Refactored component structure

The Philosophy

Tests should fail when user-facing behavior changes, not when implementation details change.

I refactored 15 components last month (table → grid layout). Zero test updates needed. Tests stayed green because user behavior didn't change.


5. I Stopped Putting Business Logic in Hooks

Before: Logic Scattered in useEffect

function useCampaigns(page) {
  const [campaigns, setCampaigns] = useState([]);
  const [loading, setLoading] = useState(false);
 
  useEffect(() => {
    setLoading(true);
 
    // Business logic buried in useEffect
    fetch(`/api/campaigns?page=${page}`)
      .then((res) => res.json())
      .then((data) => {
        // Response normalization here
        const normalized = data?.data?.info ?? [];
        setCampaigns(normalized);
      })
      .finally(() => setLoading(false));
  }, [page]);
 
  return { campaigns, loading };
}

Problems:

  • Can't test response normalization without mounting React
  • Can't reuse fetch logic outside hooks
  • Business rules mixed with React lifecycle

After: Services Contain Logic, Hooks Coordinate

// services/campaignService.ts (pure TypeScript, no React)
export async function getCampaigns(page = 1): Promise<Campaign[]> {
  const resp = await api.get(`/campaigns?page=${page}`);
  const data = resp?.data?.data ?? { info: [] };
  return data.info ?? [];
}
 
// hooks/useCampaigns.ts (coordination only)
export function useCampaigns(page = 1) {
  const { data, error } = useSWR(["/campaigns", page], () =>
    campaignService.getCampaigns(page)
  );
 
  return {
    campaigns: data ?? [],
    isLoading: !error && !data,
    error,
  };
}

Now I can test the service independently:

// No React needed
it('normalizes campaign response', async () => {
  mock.onGet('/campaigns').reply(200, { data: { info: [...] } });
 
  const result = await getCampaigns(1);
 
  expect(result).toHaveLength(10);
  expect(result[0]).toHaveProperty('id');
});

Why Separation Matters

When a requirement changed ("support bulk campaign creation"), I:

  1. Modified campaignService.ts (added createCampaigns() function)
  2. Added a hook (useBulkCreate())
  3. Used it in a component

Zero changes to existing components. New feature in 30 minutes.


What Actually Matters

After 6 months in production, here's what improved code quality:

Not This:

  • ❌ Latest framework version
  • ❌ Clever abstractions
  • ❌ 100% test coverage
  • ❌ Trendy state management library

But This:

  • Clear boundaries (services don't import React)
  • Explicit contracts (TypeScript interfaces for all service calls)
  • Testable design (can test logic without mounting components)
  • Documented decisions (ADRs for every significant choice)

The Numbers

BeforeAfterImprovement
Redux boilerplateSWR hooks85% less code
Client refresh logicBackend handles it0 race condition bugs
Role checks everywherePermission service0 code changes when adding roles
Snapshot testsIntegration tests0 false failures on refactors
Logic in hooksLogic in servicesServices testable without React

Try This Tomorrow

Pick one thing to stop doing:

  1. If you use Redux for API calls: Try SWR or React Query for one feature
  2. If you check user.role everywhere: Implement hasPermission(resource, action)
  3. If you write snapshot tests: Write one integration test that proves behavior
  4. If you have logic in useEffect: Extract it to a service file

You don't need to refactor everything. Start with one new feature. See how it feels.


The Full Case Study

This post covers 5 decisions from a larger project. For the complete architecture analysis including:

  • Why I separated services from React components
  • How I designed the permission system
  • What testing strategy caught the most bugs
  • The tradeoffs I accepted and why

Read the full case study: GitHub - Marketing Platform Architecture

All architectural decisions are documented in ADR format with:

  • The problem and alternatives considered
  • Why I chose each approach
  • The tradeoffs accepted
  • When I'd reconsider