This guide focuses on React-specific habits that pair well with TypeScript. It assumes you already follow the TypeScript best practices document; concepts already covered there are not repeated here.
- app
- public/
- src/
- assets/
- common/
- components/
- hooks/
- models/
- pages/
- styles/
- App.ctx.tsx
- App.test.tsx
- App.tsx
- index.css
- index.tsx
- react-app-env.d.ts
- reportWebVitals.ts
- .env
- .eslintrc.json
- .gitignore
- README.md
- package.json
- tsconfig.json
public/– static assets generated by Create React App (favicons, manifest, etc.).src/– all application code:assets/– downloaded or third-party resources such as images and fonts.common/– reusable constants, types, and utilities.- Some examples of nested folders:
utils/,constants/,hooks/,components/,styles/etc
- Some examples of nested folders:
models/– modules that represent data entities (for exampleUser.ts).pages/– route-aligned entry points. Mirror the navigation tree so folders reflect how users reach a page. If a URL such ashttps://my-site.com/posts/9Z8AO5C844Rrenders<View/>, the folder should reflectPosts/View/to make routing obvious.
- Name files and folders after the React component they represent. File names of single-file components should share the component name. Multi-file components should have a folder named after the component with an
index.tsx(barrel) default entry.
- common/
- constants/
- Paths.ts
- types/
- utils/
- components/
- hooks/
- styles/
- Colors.ts
- BoxStyles.ts
- models/
- User.ts
- Post.ts
- pages/
- Home/ (https://my-site.com/home)
- index.tsx
- index.test.tsx
- Account/ (https://my-site.com/account)
- UpdatePaymentForm/
- ValidatePaymentInfo.tsx
- index.tsx
- index.test.tsx
- index.tsx // imports <UpdatePaymentForm/>
- index.test.tsx
- Posts/ (https://my-site.com/posts)
- common/
- types.ts // shared across View/Edit/New
- components/
- PostForm.tsx // shared between New and Edit
- Edit/ (https://my-site.com/posts/:id/edit)
- index.test.tsx
- index.tsx
- New/ (https://my-site.com/posts/new)
- index.test.tsx
- index.tsx
- View/ (https://my-site.com/posts/:id)
- index.test.tsx
- index.tsx // displays a specific post
- index.tsx // shows <PostsTable/> when no post is selected
- index.css- Use PascalCase component names and prefer function declarations over classes or arrow functions so components are hoisted. function-declarations are also better for stack tracing errors.
- Define parent components first and declare children beneath them in the same file to keep logic top-down.
- Always type props. Define an interface (e.g.
IProps) in theTypesregion for complex components. - You do not need to specify a return type for component functions because React components always return
JSX.Element.
- Keep static values outside component functions under the
Constantsregion; otherwise, they are reinitialized on every render. - Extract long, reusable helpers out of the component body and place them in the
Functionsregion so they are not recreated on each render. - Place layout logic for sibling components in their parent so that positioning and interactions are visible in one place.
- Prefer whitespace plus short comments to separate hook calls, DOM chunks, and initialization inside a function component.
- Keep the default export at the bottom. When the default export is a function component, start its comment with
Default component ...so readers can identify it quickly without scrolling. - Avoid giant
returnstatements. Create child components for related DOM blocks rather than declaring many JSX variables above the return. Snippet 2 illustrates how composing smaller components keeps the parent lean. - Follow the convention of function declarations at the top level and arrow functions only for inline callbacks inside JSX.
// Bad: logic split across temporary JSX variables
export default function Parent() {
const posts: string[] = [];
const name = '';
let Child = null;
if (something) {
Child = (
<Box mb={2}>
Name: {name ?? ''} Posts: {posts?.length ?? 0}
</Box>
);
} else {
Child = (
<Box {...otherProps}>
Foo: {name} Bar: {posts.length}
</Box>
);
}
return (
<Box>
<Child />
<SomeOtherChild />
</Box>
);
}// Good: extract child components
import Box, { BoxProps } from '@mui/material/Box';
interface IChildProps extends BoxProps {
name?: string;
posts?: string[];
}
/**
* Default component. Display a list of <Child/> elements.
*/
function Parent() {
return (
<Box>
{something ? (
<Child1 mb={1} name={name} posts={posts} />
) : (
<Child2 name={name} posts={posts} />
)}
<SomeOtherChild />
</Box>
);
}
/** Display a child's name and number of posts. */
function Child1(props: IChildProps) {
const { name = '', posts = [], ...otherProps } = props;
return (
<Box {...otherProps}>
Name: {name} Posts: {posts.length}
</Box>
);
}
/** Lorum Ipsum. */
function Child2(props: IChildProps) {
const { name = '', posts = [], ...otherProps } = props;
return (
<Box {...otherProps}>
Foo: {name} Bar: {posts.length}
</Box>
);
}
export default Parent;- Destructure props at the top of the component. It is easier to set defaults and identify unused values, and wrapper components can mirror the child props by reusing the same names.
- Snippet 3 shows how to extract props, separate regions, and organize hooks.
// LoginForm.tsx
import { useCallback } from 'react';
import axios from 'axios';
import Box, { BoxProps } from '@mui/material/Box';
import Button from '@mui/material/Button';
import Indicator from 'components/md/Indicator';
/******************************************************************************
Components
******************************************************************************/
/**
* Default component. Login a user.
*/
function LoginForm(props: BoxProps) {
const { sx, ...otherProps } = props,
navigate = useNavigate();
// Init state
const [state, setState, resetState] = useSetState({
username: '',
password: '',
isLoading: false,
});
// Call the "submit" API. Just an example, you should never call axios
// directly in a component.
const submit = useCallback(async () => {
setState({ isLoading: true });
const resp = await axios.post({
username: state.username,
password: state.password,
});
const isSuccess = _someLongComplexFn(resp);
setState({ isLoading: false });
if (isSuccess) {
navigate('/account');
}
}, [setState, state.username, state.password, navigate]);
// Return
return (
<Box
sx={{
position: 'relative',
...sx,
}}
{...otherProps}
>
{/* Indicator */}
{state.isLoading && <Indicator />}
{/* Input Fields */}
<TextField
type="text"
value={state.username}
onChange={v => setState({ username: v.currentTargetValue })}
/>
<TextField
type="password"
value={state.password}
onChange={v => setState({ password: v.currentTargetValue })}
/>
{/* Action Buttons */}
<Button color="error" onClick={() => navigate('/home')}>
Cancel
</Button>
<Button color="primary" onClick={() => submit()}>
Login
</Button>
</Box>
);
}
/******************************************************************************
Functions
******************************************************************************/
function _someLongComplexFn(data: AxiosResponse<unknown>): boolean {
// ...do stuff
return true;
}
/******************************************************************************
Export default
******************************************************************************/
export default LoginForm;State management typically combines useState, useContext, and sometimes a third-party library such as Redux. Use the lightest tool that satisfies the data flow you need.
useStateis fine for components with one or two pieces of state. As state grows, switch to a custom hook (such asuseSetState) that manages a single state object.- Grouping state into one object keeps every state value prefixed with
stateand managed by a single updater, improving readability. - Hooks such as
resetStateare especially helpful in modal flows or anywhere you need a quick way to restore defaults.
- If data only passes down one level within the same file, props are enough. Once data flows through multiple files or deeply nested trees, switch to
useContextor a global state manager (Redux, Zustand, etc.). - When a component mixes heavy DOM content with
useContextwiring, split the provider into a sibling file suffixed with.provider.tsx. The provider file should focus solely on context creation, default values, and exports for hooks (Snippet 4). Keep components out of provider files to retain fast reloads. - Remember that
useContextrerenders the consuming component and its children. Use multiple providers and scope each as low as possible to avoid unnecessary rerenders.
// App.provider.ts
import { createContext, useContext } from 'react';
const AppContext = createContext({
setSessionData: (data: unknown) => {
// ...do stuff
},
});
export const useAppContext = () => useContext(AppContext);
export default AppContext.Provider;// App.tsx
import AppProvider from './App.provider';
function App(props) {
const { children } = props;
const [session, setSession] = useState({});
const resetSessionData = useCallback(newData => {
const newSession = /* ...bunch of logic */ {};
setSession(newSession);
}, [setSession]);
return (
<AppProvider
value={{
session,
resetSessionData: val => resetSessionData(val),
}}
>
<NavBar>Hello {session.userName}</NavBar>
<Home />
{/* ...some large amount of jsx code */}
{children}
</AppProvider>
);
}
export default App;These items may not be enforced by the linter but they help keep React + TS projects readable.
- Keep color tokens in
src/common/styles/Colors.tsinstead of hardcoding hex strings in JSX. Group base colors, then expose them through semantic buckets so updates stay centralized.
// src/common/styles/Colors.ts
const Base = {
Grey: {
UltraLight: '#f2f2f2',
Lighter: '#e5e5e5',
Light: '#d3d3d3',
Default: '#808080',
Dark: '#a9a9a9',
Darker: '#404040',
UltraDark: '#0c0c0c',
},
Red: {
Default: '#ff0000',
Dark: '#8b0000',
},
White: {
Default: '#ffffff',
},
};
export default {
Background: {
Default: Base.Grey.Default,
White: Base.White.Default,
Hover: Base.Grey.Light,
},
Border: Base.Grey.Dark,
Text: {
Error: {
Default: Base.Red.Default,
Hover: Base.Red.Dark,
},
},
};import Colors from '@src/common/styles/Colors';
function Foo() {
return (
<div>
<div
style={{
marginBottom: 16,
fontSize: 12,
backgroundColor: Colors.Background.Default, // never '#808080'
}}
>
Hello
</div>
<div
id="how-are-you-ele"
style={{ padding: 8 }}
onClick={() => alert('How are you?')}
>
How are you?
</div>
</div>
);
}- Give parameters meaningful names in general, but for simple inline JSX callbacks you can use a short placeholder like
vforvalueorerrforerrorwhen it only takes one line of logic. This keeps JSX uncluttered while still distinguishing callback data from other variables.
function Parent() {
const [state, setState] = useSetState({
name: '',
nameError: false,
email: '',
emailError: false,
});
return (
<div>
<CustomInput
value={state.name}
isRequired={true}
onChange={(v, err) => setState({ name: v, nameError: err })}
/>
<CustomInput
value={state.email}
isRequired={true}
onChange={(v, err) => setState({ email: v, emailError: err })}
/>
<button
disabled={state.nameError || state.emailError}
onClick={() => {/* some API call */}}
>
Submit
</button>
</div>
);
}
function CustomInput(props: {
value: string;
isRequired: boolean;
onChange: (value: string, error?: boolean) => void;
}) {
const { value, isRequired, onChange } = props;
return (
<div>
<input
type="text"
value={value}
onChange={v => onChange(v.trim(), isRequired && !v)}
/>
<div>{!value && isRequired ? 'Value is required' : ''}</div>
</div>
);
}- Use single quotes for standard JS/TS code and double quotes for JSX attributes. Configure the linter to enforce this consistently.