Back to Articles
React
Next.js
Architecture
Patterns

My Simple React Techniques

June 5, 20268 min read

After building across Next.js, React Router, and Keycloakify projects, these are the techniques that keep my React code fast, readable, and maintainable.


These are the techniques I use across every React project I build.

They are not complex. They are not new. But they are the difference between a codebase that slows you down and one that lets you move.

Some of these might seem obvious. That is the point.

1. Server-First, Not useEffect-First

If your first instinct is to fetch data inside useEffect, you are already thinking client-first.

The modern React frameworks (Next.js App Router, React Router v7 with loaders) make this unnecessary in most cases.

Server Components fetch directly:

export default async function Page() {
  const data = await fetch('https://api.example.com/data')
  return <div>{data.title}</div>
}

React Router loaders fetch before rendering:

export async function loader({ request }) {
  const data = await fetchDashboardData(request)
  return { data }
}

export default function Page() {
  const { data } = useLoaderData()
  return <div>{data.title}</div>
}

Read more about Using Next.js As Intended

In both cases, the component receives data as a prop. No useEffect. No loading state flicker. No client-side waterfall.

useEffect should be reserved for:

  • Browser-only APIs
  • Subscriptions (WebSocket, events)
  • Imperative side effects
  • One-time initialization

If data is required to render the page, fetch it on the server.

2. Context vs Zustand: Use the Right Size Tool

I use both. The trick is knowing which layer needs which.

Zustand is for cross-tree, persistent, or frequently accessed state:

  • Cart
  • Authentication
  • Notifications
  • Real-time UI state
// Multiple stores, one per domain
export const useCartStore = create<CartStore>()(
  devtools(
    immer(
      subscribeWithSelector((set) => ({
        cart: {},
        addToCart: (data) =>
          set((state) => {
            state.cart[data.productId] = data
          }),
      }))
    )
  )
)

Context is for localized, subtree-bound state:

  • Checkout form state
  • Socket connection
  • Modal state within a specific layout
// CheckoutContext only lives inside the checkout route
<CheckoutContextProvider>
  <CheckoutOrderForm />
</CheckoutContextProvider>

Rule: if the state only matters inside one route or feature, use Context. If it crosses route boundaries or needs to persist, use Zustand.

Read more about Zustand vs Redux

3. File Colocation

Components, actions, tests, schemas, and helpers should live together.

In Next.js App Router:

app/checkout/
  page.tsx              # server page
  checkout-context.tsx  # client context
  checkout-form.tsx     # client form
  actions/
    secure-checkout.ts  # server actions
    types.ts            # shared types
    secure-checkout.test.ts  # colocated tests

In React Router v7:

routes/_internal/campaigns/
  route.tsx          # layout
  _index/route.tsx   # list view
  new/route.tsx      # create form
  new/route.test.ts  # test for action
  $campaignId/route.tsx
  $campaignId/preview/route.tsx

If you have to jump across five directories to change a button, your file structure is fighting you.

4. Categorize Lib and Utils

Not everything is a "utility." I separate them by whether they have side effects.

src/lib/ — Core utilities with side effects:

  • Analytics
  • Toast notifications
  • Socket connections
  • Server actions
  • API wrappers

src/utils/ — Pure helper functions:

  • promiseHash — resolves object of promises
  • debounce — standard debounce
  • formatCurrency — pure formatting
  • stringToSentenceCase — text transforms
// src/utils/promiseHash.ts — pure, no side effects
export const promiseHash = async <T extends Record<string, Promise<any>>>(
  promises: T
): Promise<{ [K in keyof T]: Awaited<T[K]> }> => {
  const entries = await Promise.all(
    Object.entries(promises).map(async ([key, value]) => [key, await value])
  )
  return Object.fromEntries(entries) as { [K in keyof T]: Awaited<T[K]> }
}

If a function touches the DOM, makes a network request, or modifies global state, it belongs in lib. If it takes input and returns output with no side effects, it belongs in utils.

5. Functions Inside Components vs Outside

This is one of the most common performance mistakes I see.

function MyComponent() {
  const handleClick = () => { ... }  // ❌ recreated every render
  return <Button onClick={handleClick} />
}

If the function is stable and does not depend on component state, move it outside:

const handleClick = () => { ... }  // ✅ defined once

function MyComponent() {
  return <Button onClick={handleClick} />
}

If it depends on state, memoize it:

function MyComponent({ id }) {
  const handleClick = useCallback(() => {
    doSomething(id)
  }, [id])
  return <Button onClick={handleClick} />
}

The rule: define functions outside the component when possible. Inside only when they need closure over state or props.

6. Memoization Is Not Magic, It Is a Boundary

I use useMemo, useCallback, and React.memo for two reasons:

  1. Preventing expensive recalculation (useMemo)
  2. Preventing child re-renders (useCallback + memo)
const filteredOrders = useMemo(() => {
  return orders.filter((order) => {
    if (statusFilter !== "ALL" && order.status !== statusFilter) return false
    if (highRiskOnly && !isHighRisk(order)) return false
    return order.orderNumber.toLowerCase().includes(query.toLowerCase())
  })
}, [orders, query, statusFilter, highRiskOnly])
const SortableItem = memo(({ item }) => {
  const style = useMemo(() => ({
    transform: CSS.Transform.toString(transform),
    transition,
  }), [transform, transition])
  return <div style={style}>{item.name}</div>
})

Do not memoize everything. Memoize where re-renders are expensive and where the dependency array is stable.

7. When to Extract to Hooks, HOCs, or Context

Extract to a custom hook when:

  • You are repeating the same useEffect + useState pattern across components
  • The logic is self-contained and returns values
  • You need to share logic without sharing UI
// useSocketIO.ts — reusable socket subscription
export const useSocketIO = <E extends keyof SocketEventMap>(
  event: E,
  cb: (data: SocketEventMap[E]) => void
) => {
  const { socket, isConnected } = useContext(SocketContext)
  useEffect(() => {
    if (!socket || !isConnected) return
    socket.on(event, cb)
    return () => { socket.off(event, cb) }
  }, [socket, isConnected, event, cb])
}

Extract to Context when:

  • Multiple components in a subtree need the same state
  • The state should not be global
  • You want to avoid prop drilling

Avoid HOCs unless you are wrapping third-party components or dealing with legacy code. Hooks have replaced most HOC use cases.

8. Do You Really Need to Fetch in the Client Component?

Most of the time, no.

Server Components fetch on the server. React Router loaders fetch before hydration. Server Actions handle mutations without client-side fetch.

The only times I fetch client-side:

  • User-triggered pagination (load more)
  • Real-time data (WebSocket, polling)
  • Data that depends on client-only state (scroll position, form input)
// Quick notification drawer — client-side fetch because it is lazy
useEffect(() => {
  if (isOpen) {
    fetchNotifications({ cursor })
  }
}, [isOpen, cursor])

If the data is needed for the initial render, fetch it on the server.

9. Functional Programming and React

React is already functional. I lean into it.

Pure utility functions for data transforms:

import { pipe, prop, omit } from 'ramda'

export const selectCartItems = pipe(selectCartStore, prop('cart'))
export const selectCartIsBusy = pipe(selectCartStore, prop('isBusy'))

Immutable state updates via Immer in Zustand:

set((state) => {
  state.cart[data.productId] = data  // Immer handles immutability
})

Array transforms instead of imperative loops:

const generalNav = [
  permissionMap.dashboard.read ? { to: "/dashboard", label: "Overview" } : null,
  permissionMap.users.read ? { to: "/users", label: "Users" } : null,
].filter(Boolean)

Functional patterns make code predictable. The same input always produces the same output. Side effects are explicit and isolated.

10. Testing Without Stress

Tests are not separate from the code. They live next to it.

like-button/
  index.tsx
  index.test.tsx
  actions.ts
  actions.test.ts

Mock dependencies, not behavior:

vi.mock('./actions', () => ({
  likeProductAction: vi.fn(),
}))

vi.mock('next/navigation', () => ({
  usePathname: () => '',
  useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
}))

Test the contract, not the implementation:

  • Does the button render the correct state?
  • Does clicking it call the action?
  • Does the API handle validation errors?

With AI tooling, writing tests is faster than ever. The hard part is knowing what to test. Focus on user-visible behavior and critical paths.

11. Server Code vs Client Code

Know where your code runs. This is the most important architectural decision in modern React.

In Next.js App Router:

  • Default is Server Components
  • Add 'use client' only when you need hooks, browser APIs, or event handlers
  • Add 'use server' on functions that need to run server-side

In React Router v7:

  • Use .server.ts suffix for server-only modules
  • Route loaders run on the server
  • Components hydrate on the client

The boundary matters because:

  • Server code can access databases, secrets, and filesystem
  • Client code can access window, document, and localStorage
  • Crossing the boundary incorrectly creates security holes and runtime errors

When in doubt, start server-side. Move to client only when the browser demands it.

Final Point

React is not complicated. The ecosystem makes it feel complicated.

These techniques are simple:

  • Fetch on the server
  • Use the right size state tool
  • Colocate files
  • Separate pure functions from side effects
  • Memoize where it matters
  • Test behavior, not implementation

They are not rules. They are defaults. Adjust them when the situation demands it.

But start here. Most React codebases I have seen would be faster, calmer, and more maintainable if they followed these patterns.

The best technique is the one that makes the next change easier.

Enjoyed this article?

Quick reaction helps me write better follow-up posts.

Connect with me