My Simple React Techniques
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 promisesdebounce— standard debounceformatCurrency— pure formattingstringToSentenceCase— 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:
- Preventing expensive recalculation (useMemo)
- 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+useStatepattern 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.tssuffix 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.