Introduction
When React was first released in 2013, it was touted as a library, not a framework. At that time, dominant web frameworks like AngularJS and Ember.js provided complete, “all-in-one” Model-View-Controller (MVC) architectures. They shipped with built-in tools for routing, form validation, HTTP data fetching, and dependency injection.
Since it is a library, there are many solutions and ways to do things, as it is unopinionated—unlike most relatives at family gatherings. Those opinionated relatives are like AngularJS and Ember.
The biggest problem React solves is keeping the UI synchronized with application state. This used to be a mammoth pain in the days of query selectors and vanilla JavaScript. Now, you’re the weird library on the block if you don’t have state management features.
In React, you describe what the screen should look like for a given state, and React figures out the DOM changes using Virtual DOM (VDOM). It sounds like really bad VR, but it’s actually very cool.
When state or props change, React creates an in-memory representation of the UI (the Virtual DOM), compares it to the previous version (a process called “diffing”), and updates only the necessary parts in the real browser DOM. This is a simplification of how modern React works internally, but it’s a useful mental model.
The React Team doesn’t really use this term anymore, so you may hear it referred to as a declarative UI runtime that relies on internal data structures like Fiber.
This post is not an exhaustive look at React, but rather, how I have come to enjoy organizing my projects in my many years of using the library.
The Mental Model of a React Component
To understand why I structure React projects the way I do, it helps to start with React’s mental model.
Imperative Code
Back when dinosaurs roamed the web, we used to write imperative UI code. This means we focused on writing step-by-step instructions to mutate the UI. This was buggy and error-prone. It also forced you to write a lot of sandwich code. For example:
// Prehistoric Imperative codeasync function loadUserData() { // First, we have to get our elements. const profileContainer = document.getElementById('user-profile'); const spinner = document.getElementById('loading-spinner'); const errorMessage = document.getElementById('error-msg');
// --- Top Bread of the sandwich: Manually setup the "Loading" UI state spinner.style.display = 'block'; errorMessage.style.display = 'none'; profileContainer.textContent = '';
try { // --- The meat of what we actually want to do: Fetch the data const response = await fetch('https://api.example.com/user'); if (!response.ok) throw new Error('Failed to fetch'); const data = await response.json();
// --- Bottom of the sandwich (Success): Manually mutate DOM to show data spinner.style.display = 'none'; profileContainer.innerHTML = `<h2>Welcome, ${data.name}</h2>`; } catch (error) { // --- Bottom of the sandwich (Failure): Manually mutate DOM to show error spinner.style.display = 'none'; errorMessage.style.display = 'block'; errorMessage.textContent = 'Critical error: Could not load profile.'; }}The core problem with this code is state synchronization. The state of the application lives implicitly in the DOM. Some of the problems with this imperative code are that you could forget to clean up after yourself, it’s mentally taxing for the developer to follow, and if someone comes in and refactors the CSS, they could break the selector(s).
Declarative Code
React code is declarative. A component is just a function that takes props (think arguments or variables that may change the component) and return markup. For example:
function Greeting({ name }) { return <p>Hello, {name}!</p>;}
// <Greeting name="Kevin" />// will always produce the same output, and React needs to only update// the text node.When props or state change, React reruns the function—in Strict mode, it runs it twice in a row to ensure it is pure (given the same input, it produces the same output). Contrary to popular belief, re-renders aren’t slow. It’s what re-rendering on the main thread does that makes things feel slow. If you have errant code that is causing lots of re-renders, it will sip CPU cycles, dropping frame rates for animations, creating a cludgy feeling when appending elements to the DOM, etc. The more errant code like this you have, the slower the application feels.
// The whole idea in eight linesfunction Greeting({ name }) { return <p>Hello, {name}!</p>;}
// Given state `name = "Kevin"`, this is always what renders.// Change name, the function runs again, React updates only the text node.One thing that confused me early on was that return at the end of a React
component. I wasn’t sure what I could put in there, but here’s a good way to
think about it:
- It just renders HTML
- When you interpolate values into that HTML, you use the
{}syntax. - Only use one-liner JavaScript within the return of a React Component/Hook:
return (someCondition ? items.map((i) => <Item key={item.id} name={item.name} />) : <NoItems />)A very common way to render a list in React is to map data to a component. That
key prop is
a special one that identifies a component in a list.
Props and State
Both props and state should be thought of as immutable, because React often
relies on reference equality
(Object.is)
when determining whether state values have changed and when optimizing
re-renders.
In React, props are read-only properties—like attributes in HTML—that get passed to a component. This type of data can only flow down the component tree. When a prop changes, it will queue up a re-render of that component.
function MyComponent({ someProp, anotherProp }) { //... Props flow downward. Think of them like reactive HTML attributes.}Reactive state is a component’s memory over time. Changing it triggers a re-render. There are a few different React hooks that manage state:
useState- A React hook (a special function) that returns a tuple, which is an immutable fixed sequence of elements. In this case, the first element in the sequence is the state, and the second is the function to set that state.useReducer- A React hook that also returns a tuple. The first element is the state that was returned by the reducer function, and the second is a function that dispatches an action to that reducer.
function MyComponent() { const [state, setState] = useState(initialState); const [state, dispatch] = useReducer(reducer, initialArg, init);
// other code.}Early on, when I first started writing React code, I often used prop drilling—passing data down multiple layers of components to reach a deeply nested child. This can become a problem when data must pass through many layers of components that don’t use it directly; intermediate components become bloated with data they don’t use, breaking the principle of single responsibility.
One of the ways to handle this, if you need to reach a faraway child, is an airplane—I mean the Context API. Instead of using a bucket brigade, Context acts like a waterfall: you wrap a parent component to expose a value to an entire subtree without manually passing props through each layer, and any component or hook can reach in and grab exactly what it needs directly.
Once you understand how data moves through a React application, the next question becomes where all of that code should live.
How I Organize a React Project
Because React is a library, not a framework, it has no opinion about where your files go. This is freeing and paralyzing in equal measure. Over the years, I’ve landed on a structure that lets the next developer—often future me, who remembers nothing—reason about the project just by scanning folders. The whole philosophy boils down to one rule: organize things so that someone can guess where a file lives before they ever open the search bar.
Here’s the shape of it:
src├── assets/ # css, images├── components/ # React components and their styles│ ├── App.tsx│ └── DropDownMenu/│ ├── DropDownMenu.tsx│ └── DropDownMenu.css├── hooks/ # useFetch.ts, useUser.ts├── context/│ └── User/│ ├── UserContext.ts│ └── UserProvider.tsx├── reducers/│ └── plotly-data/│ ├── actions.ts│ ├── reducers.ts│ └── types.d.ts├── util/ # pure functions: dates.ts, formatting.ts├── constants/ # colors.ts, conversion.ts├── errors/ # MainErrorBoundary.tsx├── types.d.ts└── main.tsxA couple of structural habits I really like are:
- First,
srcholds all the source code, and a siblingtestfolder mirrors its structure exactly—if you know where the component lives, you know where its test lives. - Second, the only
.tsxfiles allowed at the top levelsrcare your entry points. Everything else gets a folder. When the top level stays clean, findingmain.tsxis trivial instead of a scavenger hunt.
A Folder for Each Kind of Thing
The top-level folders inside src aren’t arbitrary—each one maps to a kind of
thing React knows about.
componentsholds your React components and any component-specific styles. If a component needs more than one file, give it aPascalCasefolder named after itself:./DropDownMenu/DropDownMenu.tsx,./DropDownMenu/DropDownMenu.css. The folder is the component.hooksholds your custom hooks. OneuseFetch.tsper concern.contextholds your Context—like the waterfall analogy from earlier. Use a folder named after the context (User), then prefix the files with that name:UserContext.tsandUserProvider.tsx. Splitting the context object from its provider keeps the “what” separate from the “how.”utilis for pure functions—same input, same output, same result. Group related functions into one file (dates.ts); resist the urge to make a file per function, or you’ll drown inimportstatements.constantsis for the values that never change. Same rule asutil: group them, don’t make a lonely file for a single color.errorsis for your Error Boundaries, and every file ends inErrorBoundaryso there’s no ambiguity about what it does.
Types that require more than one file go in a top-level types.d.ts. The .d
stands for declarations, and a nice trick is that TypeScript treats a
declarations file with no imports or exports as global—the types are just
there, no import required. When that file gets fat, promote it to a types/
folder using the same grouping rules as util and constants. Prefer using
.d.ts files over importing types to reduce import clutter.
Dumb Components, Smart Hooks
You will save yourself a lot of pain and consternation if you follow this one simple rule: components should be dumb. A component’s job is to render markup. That’s it. The moment a component starts managing data fetching, transformations, and synchronization logic, it quickly becomes unmaintainable.
So I push all of that into custom hooks. So a component reads like a description of the screen, whereas a hook describes a single responsibility. You don’t need to extract a hook for every little thing—the React docs say some duplication is fine.
That said, you should really scrutinize your components as you write them to
make sure they continue to read like a description of the screen. One of the
ways things can get out of hand is by having too many useEffects in a
component. When I first discovered the useEffect hook, I definitely abused it
(e.g., hot potatoing state from one Effect to another, using it for console logs
when state changes, things like that).
Since then, I think critically about whether I even need one in my components or custom hooks. When deciding, I ask myself three questions:
- “Do I need an Effect for this?” - Most of the time, you don’t need a
useEffect. Props and state changing will cause a re-render to happen, which eliminates a whole category of why people reach foruseEffectbecause you can write that logic in the component body or the Effect body. The next kind of use case is reacting to a state to set another state; this is a very slippery slope, more on that later. The idea is to scrutinize whether you absolutely need the Effect. - “Does this make more sense in a named custom hook?” - It’s possible, if
you’re thinking about adding an Effect, that you already have created some
new state to track. If it’s not concerned with rendering, it might be good to
lift all those pieces up and put them into a custom hook. For example, screen
resizing with
ResizeObserver, data fetching, data manipulation, API synchronization, authentication/authorization, etc. These are all examples of things that aren’t concerned with rendering. - “Is this component still primarily concerned with rendering?” - This is
an important one. You may have determined that your
useEffectusage is simple enough that you don’t need to put it in a custom hook, and you’ve already determined it’s needed. So the real question is: does it distract from rendering? For example, maybe it’s simply an analytics call that gets fired on click. Simple enough, right? But what if you have to add 5 or 6 of them? For me, it comes down to how readable the component is after that: can I still easily tell what is being rendered.
The thing to remember here is that Effects run after React commits updates and exist specifically for synchronizing with external systems. This is intentional. They’re meant to be a side effect of rendering a component. The React documentation even says:
If you’re not trying to synchronize with some external system, you probably don’t need an Effect. — react.dev as of 06-02-2026
Think of it like being hungry and ordering DoorDash. Once your order is placed,
that’s all asynchronous to you. You can go about your business without freezing
in place until the driver rings your doorbell. This is a similar reason to use a
useEffect when you want something external to happen. External may refer to
the DOM, an API, localStorage, etc.
Here’s an example of a hook that uses ResizeObserver to determine the window
dimensions. The component doesn’t need to tangle itself up in this logic; it can
be lifted into a hook called useWindowDimension. Then the component can stay
focused on rendering.
// The component stays focused on rendering.function MyComponent() { const { width, height } = useWindowDimensions(); // ...render something with width and height}The one anti-pattern I’ll call out by name: don’t hot-potato state between Effects. You know the move—one Effect sets a piece of state, which triggers a second Effect, which sets more state, which triggers a third. It feels reactive and clever. It is sadistic. It’s an abuse of reactivity, and when a bug in that chain of events happens, you will question the life choices that led you to become a software developer. I’ve experienced it many times!
And remember the rule that trips everyone up: hooks go at the top level,
always, in the same order. React matches your hooks up by call order, so you
can’t tuck one behind an if. Bail out after all your hooks have run, not
before:
function MyComponent({ someProp }: MyComponentProps) { const [state, setState] = useState(); useEffect(() => { /* ... */ }, []);
if (!someProp) return null; // ✅ after the hooks, not before
return <div>{/* ... */}</div>;}Naming Is Half the Battle
Consistent casing turns filenames into documentation. The convention I follow mirrors what you’ll see in the React docs themselves:
camelCasefor hooks (useWindowDimensions.ts).PascalCasefor components, classes, context providers, and error boundaries.kebab-casefor everything else—plain JavaScript, images, non-component CSS. The casing alone tells you whether you’re looking at React-specific code or ordinary functional code.
I also prefer plain function declarations over
arrow-function-assigned-to-const, and I’m consistent about exports: hooks
get a named export, components get a default export.
// ✅ Hook: function + named exportexport function useWindowDimensions() { /* ... */ }
// ✅ Component: function + default exportfunction MyComponent({ someProp }: MyComponentProps) { /* ... */ }export default MyComponent;Is this bikeshedding? A little. I generally prefer function declarations because I’ve been bitten by declaration-order issues more than once. With function declarations, JavaScript hoists the function definition, so the function can be called before its declaration appears in the file. That makes refactoring easier, since I don’t have to worry as much about rearranging code when functions depend on one another. Because I already use this style in utility files and within component and hook implementations, it also feels more consistent to use function declarations for custom hooks and components.
Most applications can get surprisingly far with components, hooks, and context alone. But eventually state management becomes complex enough that you need to reach for another pattern: reducers.
Reducers: When State Gets Complicated
Sometimes useState isn’t enough. When you’ve got a pile of state updates
scattered across a dozen event handlers,
useReducer
lets you consolidate all that logic into a single function and reason about it
in one place.
The keyword there is complicated. A reducer that just flips a boolean isn’t a reducer, it’s component state wearing a costume. Reach for a reducer when the state logic is genuinely complicated. For example, form state, state the need to change together because they affect each other, loading/error states, when you need to fetch configuration from an API then the client can change it, and many other complexities.
My rule of thumb, on whether it’s complex, is:
- Multiple states need to change together.
- There are more than 2 or 3 actions required to update it. Meaning you are calling for state updates in more than 2 or 3 places, and they’re each slightly different payloads.
When it does, I give each reducer its own kebab-case folder under reducers/,
named after the state it manages, with three files inside:
types.d.ts— the action types. This is where that global-declarations trick pays off: define your action union here, and you can use those types across the reducer and actions without importing them anywhere. An action always needs atype, and I use apayloadfor the inputs that determine the next state.actions.ts— action creators, one function per action. Extracting these means that when the payload shape changes, I edit it in one place instead of hunting down everydispatchcall in the app. Bonus: it’s strongly typed, so a bad payload lights up red immediately.reducers.ts— the reducer function itself.
That last file has one non-negotiable rule. React compares the previous state
value and next state value using
Object.is.
For objects and arrays, that effectively means React is comparing references
rather than their contents. If you mutate the existing state object, React sees
the same reference and shrugs; nothing re-renders. So a reducer must always
return a new object:
case PlotlyDataActionTypes.UPDATE: { const next = { ...state }; // shallow copy → new reference → React notices // ...mutate the copy return next;}The happy side effect of this whole structure is that reducers and actions are just pure functions. Pure functions are trivial to unit test—no rendering, no mocking the DOM, just given this state and this action, do I get the right state back?
A Few Conventions Worth Adopting Early
- One component per file; the file is named after the component.
- Barrel
index.tsfiles — when they help (clean imports) and when they bite (circular deps, bundle bloat). Be honest about the tradeoff. - Keep components dumb, push logic into hooks/util. (This is the through-line into the next two posts — tease them.)
UI Goes Down, Events Come Up
Tying it all together is a single mental model for how data moves. Props flow down. Callbacks come up. UI components encapsulate their own markup and styles, staying focused on turning props into pixels. When something happens—a click, a change—they don’t reach out and mutate the outside world; they call a callback prop and let the parent decide what to do. The core data lives in external sources (APIs, context, reducers), and presentation components simply consume it.
Keep that boundary clean, and your app becomes easy to trace: data flows one direction, events flow the other, and you’re never left wondering who changed what.
Conclusion
None of these conventions are required. React is intentionally flexible, and plenty of successful teams organize their code differently. What matters is consistency. If developers can predict where code lives, understand how data flows, and keep rendering concerns separate from application logic, the project remains approachable long after the excitement of starting it has faded.
These same ideas show up again when designing components and building reusable UI systems, which is where I’ll head next.
This also comes in handy when designing components, but that’s a post for another day.