Skip to content

seanpmaxwell/React-Ts-Best-Practices

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

77 Commits
 
 

Repository files navigation

React + TypeScript Best Practices

GitHub stars

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.

Table of contents


Project structure

Overview

- 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

Directory roles

  • 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
    • models/ – modules that represent data entities (for example User.ts).
    • pages/ – route-aligned entry points. Mirror the navigation tree so folders reflect how users reach a page. If a URL such as https://my-site.com/posts/9Z8AO5C844R renders <View/>, the folder should reflect Posts/View/ to make routing obvious.

Structuring components

  • 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.

Example layout

- 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

Functional components

Declaring components

  • 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 the Types region for complex components.
  • You do not need to specify a return type for component functions because React components always return JSX.Element.

Organizing component code

  • Keep static values outside component functions under the Constants region; otherwise, they are reinitialized on every render.
  • Extract long, reusable helpers out of the component body and place them in the Functions region 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 return statements. 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.

Snippet 2 – parent vs child composition

// 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;

Working with props

  • 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.

Snippet 3 – component layout template

// 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

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.

useState vs useSetState

  • useState is fine for components with one or two pieces of state. As state grows, switch to a custom hook (such as useSetState) that manages a single state object.
  • Grouping state into one object keeps every state value prefixed with state and managed by a single updater, improving readability.
  • Hooks such as resetState are especially helpful in modal flows or anywhere you need a quick way to restore defaults.

useContext or global state

  • 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 useContext or a global state manager (Redux, Zustand, etc.).
  • When a component mixes heavy DOM content with useContext wiring, 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 useContext rerenders the consuming component and its children. Use multiple providers and scope each as low as possible to avoid unnecessary rerenders.

Snippet 4 – context provider split

// 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;

Misc styling rules

These items may not be enforced by the linter but they help keep React + TS projects readable.

Styling the UI

  • Keep color tokens in src/common/styles/Colors.ts instead of hardcoding hex strings in JSX. Group base colors, then expose them through semantic buckets so updates stay centralized.

Snippet 5 – color tokens

// 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,
    },
  },
};

Snippet 6 – using shared colors

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>
  );
}

Callback parameter names

  • Give parameters meaningful names in general, but for simple inline JSX callbacks you can use a short placeholder like v for value or err for error when 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>
  );
}

Other rules

  • Use single quotes for standard JS/TS code and double quotes for JSX attributes. Configure the linter to enforce this consistently.

About

Documentation for best practices to use with React with Typescript

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published