How to Understand a React Codebase: Trees, State and Hidden Logic

A practical method for reading a React app: entry point, router, provider stack, state by category, data fetching, and the places logic hides in effects and hooks.

By The OpenVisio Team · · 11 min read

React gives you a small set of primitives and very few rules about how to arrange them. Two React apps of the same size can look nothing alike: one keeps state in Redux and data fetching in thunks, another keeps everything in React Query and a handful of contexts, a third has grown three generations of patterns side by side. That freedom is why reading an unfamiliar React codebase feels harder than its size suggests.

The good news is that every React app still has the same skeleton: an entry point that mounts a root, a router that maps URLs to screens, a stack of providers that make shared state available, components that render, and hooks that hold behavior. This guide walks through that skeleton in order and then points at the places where logic tends to hide, so you can build a working model of a React app in an afternoon instead of a sprint.

Key takeaways

  • Find the mount point first (createRoot(...).render(...)), then the router, then the provider stack. Those three files explain the app's global shape.
  • Classify state by kind: server cache, global client state, URL state, form state and local UI state. Most confusion comes from not knowing which kind you are looking at.
  • Custom hooks are where behavior lives. A list of every use* function is a better table of contents than the component folder.
  • useEffect is the most common hiding place for business logic. Read effects with suspicion and note what triggers each one.
  • Use React DevTools to see the real tree and what re-renders, instead of reconstructing it from imports.
  • Pick one screen and trace it from route to API call to rendered list before reading anything else in depth.

Step 1: Find the entry point

Open package.json and look at the scripts and dependencies. They tell you the build tool and the major libraries before you read any code:

  • vite and an index.html at the root: a Vite single-page app, entry usually src/main.tsx.
  • react-scripts: Create React App (deprecated, but common in older code), entry src/index.tsx or src/index.js.
  • next, @remix-run/*, react-router v7 in framework mode, or @tanstack/react-start: a framework that owns routing and rendering. For Next.js, see how to understand a Next.js codebase.
  • react-native or expo: a mobile app; the entry is usually App.tsx or the main field.

For a Vite or CRA app, the entry point looks like this:

// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

If you cannot find it, search for the call itself:

grep -rn "createRoot\|ReactDOM.render\|hydrateRoot" src | head

ReactDOM.render means React 17 or older, which tells you which APIs and libraries to expect.

Step 2: Read the provider stack

The component rendered at the root, usually App.tsx, typically wraps the router in a stack of providers. Read it slowly. Each provider is a piece of global infrastructure:

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider>
        <ThemeProvider>
          <FeatureFlagsProvider>
            <RouterProvider router={router} />
          </FeatureFlagsProvider>
        </ThemeProvider>
      </AuthProvider>
    </QueryClientProvider>
  )
}

For each provider, write down what it provides and where it is defined. Order matters: a provider can only use context from providers above it, so FeatureFlagsProvider above can read the auth context but not the other way around. Custom providers like AuthProvider are worth opening early, since they often contain token refresh logic, redirects and API client setup.

Then list every context in the codebase:

grep -rn "createContext" src --include=*.tsx --include=*.ts

A handful of contexts is normal. Dozens usually mean context is being used as a general state container, which affects how you should read re-render behavior later.

Step 3: Map routes to screens

Find the router definition. With React Router (v6.4 and later) it often looks like this:

const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    errorElement: <ErrorPage />,
    children: [
      { index: true, element: <Home /> },
      { path: 'projects/:projectId', element: <ProjectPage />, loader: projectLoader },
      { path: 'settings/*', element: <Settings /> },
    ],
  },
])

Older code uses <BrowserRouter> with nested <Routes> and <Route> elements in JSX. TanStack Router uses file-based routes under src/routes/ and a generated routeTree.gen.ts. Either way, produce a simple table of URL, screen component and any loader or guard. Pay attention to layout routes (routes with children and an <Outlet />) and to wrapper components like <RequireAuth>, which encode access rules.

Step 4: Sort state by kind

Most of the difficulty in a React codebase is state. Before reading a single reducer, learn to classify what you see. There are roughly five kinds, and good codebases keep them in different tools:

KindTypical toolsExampleSource of truth
Server cacheTanStack Query, SWR, RTK Query, ApolloList of projectsThe backend
Global client stateRedux Toolkit, Zustand, Jotai, contextCurrent workspace, sidebar openThe browser session
URL stateRouter params, search params?tab=members&page=2The address bar
Form stateReact Hook Form, Formik, controlled inputsUnsaved editsThe form until submit
Local UI stateuseState, useReducerIs this dropdown openThe component

Search for each category so you know which tools the app actually uses:

grep -rln "useQuery\|useSWR\|useMutation" src | wc -l
grep -rln "useSelector\|useDispatch\|from 'zustand'" src | wc -l
grep -rln "useSearchParams\|useParams" src | wc -l

The most common source of bugs and confusion is the same data held in two kinds of state at once: server data copied into Redux or useState, then kept in sync by hand with effects. When you find that, note it. It is usually where stale-data bugs come from.

If the app uses Redux Toolkit, read the store setup (configureStore) and the list of slices. Each createSlice call gives you a name, initial state, reducers and, often, extraReducers for async thunks. If it uses Zustand, each create() call is a store; look for the selectors components use, since they determine re-render behavior.

Step 5: Follow the data fetching

Find where HTTP calls are made. Ideally there is one API client module and every request goes through it:

grep -rn "fetch(\|axios\.\|ky\.\|graphql(" src --include=*.ts --include=*.tsx | grep -v test | head -40

Good signs: a single api/ or services/ folder, typed request functions, and query hooks that wrap them (useProjects() calling api.projects.list()). Warning signs: fetch calls scattered inside components, response shapes cast with as, and the same endpoint called from three places with slightly different parameters.

With TanStack Query, look at query keys. They define the cache, and invalidateQueries calls tell you which mutations refresh which data:

grep -rn "queryKey\|invalidateQueries" src | head -40

A list of query keys alongside the mutations that invalidate them is a compact map of the app's data model from the client's point of view.

Step 6: Read custom hooks as the real table of contents

Components show you what the app looks like. Custom hooks show you what it does. List them:

grep -rhoE "export (function|const) use[A-Z][A-Za-z0-9]+" src | sort | uniq

Hooks like useCurrentUser, usePermissions, useAutosave, useWebSocket or useCheckout each encapsulate a behavior that several components share. Reading the ten most-imported hooks often gives you a better understanding of the app than reading the hundred components that use them. To rank them, count imports:

for h in $(grep -rhoE "export (function|const) use[A-Z][A-Za-z0-9]+" src | awk '{print $3}' | sort -u); do
  echo "$(grep -rlw "$h" src | wc -l) $h"
done | sort -rn | head -20

Step 7: Know where logic hides

React's flexibility means behavior ends up in places you would not look first. Check these deliberately.

Effects. useEffect is the most common hiding place. An effect that fetches, syncs one state into another, subscribes to an event, or redirects on a condition is business logic, but it is invisible from the component's JSX. For each effect, note the dependency array and ask what triggers it. Effects that set state derived from props or other state can usually be replaced with a computed value, and effects that fire requests on mount are a sign the app predates a data-fetching library.

Providers. As above, a provider's body can run effects for the entire app: polling, token refresh, analytics.

Higher-order components and render props. In older code, withAuth(Component) or connect(mapState)(Component) wraps behavior around components. The real component is the inner one; the export is the wrapper.

Event handlers. Long onSubmit or onClick handlers inline in JSX often contain validation, API calls and navigation. They are easy to skim past because they sit in markup.

Interceptors and middleware. Axios interceptors, Redux middleware and fetch wrappers can rewrite requests, retry, refresh tokens or redirect on 401 for every call.

Module-level side effects. Code at the top level of a module runs on import. Search for SDK initializations (Sentry.init, posthog.init) and singletons.

Step 8: Use the tools React gives you

Static reading only goes so far. Install React DevTools and run the app locally:

  • The Components tab shows the actual tree, including providers and the props and hooks state of each component. Selecting a component and using "view source" jumps to its file.
  • The Profiler records a session and shows which components rendered and why. Turn on "Highlight updates when components render" in settings and click around; anything that flashes when it should not is a re-render you will want to understand.
  • If the app uses Redux, Redux DevTools shows every dispatched action and state diff, which is the fastest way to learn what a click does.
  • The browser's Network tab, filtered to Fetch/XHR, shows exactly which endpoints a screen calls.

A worked trace

Pick one screen, say the project page. A good trace answers, in order: which route renders it (projects/:projectId), which component (ProjectPage), which hooks it calls (useProject(projectId), usePermissions()), what those hooks fetch (GET /api/projects/:id), how the result is cached (query key ['project', id]), what mutations on the page invalidate it, and which child components receive the data. Write that down in ten lines. Do the same for one form and one list, and you will have seen most of the patterns the codebase uses. The broader technique is in how to read an unfamiliar codebase.

Checklist

  1. Identify the build tool and framework from package.json.
  2. Find createRoot and read App.tsx and the provider stack.
  3. List every createContext and what it holds.
  4. Write the route table.
  5. Classify state into server, global, URL, form and local.
  6. Find the API client and list query keys and invalidations.
  7. List custom hooks and rank them by usage.
  8. Read every useEffect in the screens you care about.
  9. Confirm your model with React DevTools and the Network tab.

For the structural part, Codebase Knowledge by OpenVisio can index the repository in your browser and lay out which components and hooks depend on which modules in Explore, and you can ask questions like "where is the auth token refreshed" in Ask and get the relevant code alongside the answer. The reading habits above still do most of the work.

Reacthooksstate managementcode reading

See it on your own repo

Paste a GitHub URL and get the day-one walkthrough — free, in your browser.

Index a repo

Keep reading