Skip to content

An opinionated React + TypeScript style guide for writing scalable, maintainable, and consistent code.

License

Notifications You must be signed in to change notification settings

mlane/react-typescript-style-guide

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

32 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

"Any fool can write code that a computer can understand. Good programmers write code that humans can understand."
β€” Martin Fowler

πŸš€ React + TypeScript Style Guide

A structured, scalable, and opinionated style guide for building maintainable React applications with TypeScript. This guide ensures consistency, clarity, and best practices across projects.

πŸ“– Table of Contents


🧠 Philosophy

This style guide is designed to ensure consistency, readability, and maintainability in React + TypeScript projects. By following a structured approach, we aim to reduce cognitive load, improve collaboration, and make codebases easier to scale.

πŸ”Ή Core Principles

  • Minimal Mental Overhead
    Code should be easy to scan and understand without requiring excessive comments or context switching. Developers should be able to predict where things are located and how they are structured.

  • Predictability
    Every file and component follows the same structure, reducing ambiguity. Naming conventions, folder structures, and function placements should remain consistent across the entire codebase.

  • Clarity Over Flexibility
    While flexibility can be useful, clarity is prioritized. The goal is not to support every possible way of writing code but to ensure that code is uniform and easy to maintain.

  • Encapsulation
    Each feature should be self-contained, meaning components, hooks, and utilities related to a feature should live in the same folder. This improves modularity and reduces cross-dependencies.

  • No Unnecessary Abstraction
    Over-engineering leads to harder-to-read code. We avoid unnecessary wrapper functions, excessive prop drilling, and premature optimizations unless there is a clear need for them.

  • Early Returns for Simplicity
    When dealing with conditionals, we return early to reduce nesting and improve readability.

  • Separation of Concerns
    Logic, UI, and state management should be properly separated to improve maintainability. Business logic should live in hooks or utility functions rather than in the UI layer.

βœ… Summary

By following this guide, teams can write cleaner, more scalable, and easier-to-maintain code. The focus is on consistency, clarity, and minimal cognitive load while following modern React + TypeScript best practices.


πŸ“‚ Folder Structure

A structured, feature-based folder organization ensures scalability, maintainability, and readability. This structure keeps related files encapsulated while providing clear separation between shared logic and feature-specific implementations.

πŸ”Ή General Folder Structure

  • Feature-based structure (pages/featureName/)

    • Each feature has its own folder inside pages/.
      • Example: pages/profile/ contains all Profile-related logic.
    • Hooks related to a specific feature must be placed inside hooks/ within that feature’s folder.
      • Example: pages/profile/hooks/useGetProfileQuery.ts for a Profile-specific query.
      • Hooks shared across multiple features should remain in common/hooks/.
    • Recommended depth: While there's no strict limit, keeping features within three levels (pages/profile/common/ProfileHero/) improves maintainability.
  • common/ for shared logic

    • Stores shared UI components, hooks, and utilities.
    • Example: common/hooks/useFlag.ts is reusable across multiple features.
  • Application-wide configurations in config/

    • This folder is only for external integrations (e.g., Google Maps, Firebase, Analytics).
    • Example: config/apollo/ApolloProvider.tsx.
  • Global constants & utils (if needed) in constants/

    • This folder is for app-wide constants and utilities that are used in multiple features.
    • If a constant or utility is used in more than one feature, move it here.
    • Example: constants/guideUtils.ts contains getGuideDetailsUrl since it is used in multiple places (e.g., dashboard & profiles).
    • Feature-specific constants and utils should remain inside the feature folder (e.g., pages/profile/profileConstants.ts).
  • Assets Handling

    • Fonts remain in assets/fonts/ for styling purposes.
    • Images belong in a separate repository, not within src/.
  • GraphQL Queries/Mutations stay in the feature root (if needed)

    • Example: useGetProfileQuery.ts, useCreateProfileMutation.ts.
    • Makes auditing API calls easier without deep nesting.
  • Standardized index.ts Usage

    • Wherever applicable, folders should contain an index.ts file to simplify imports.
    • This allows cleaner imports and prevents deep import paths.
      • Example:
        import { ProfileHero } from 'src/pages/profile/common'
        Instead of:
        import { ProfileHero } from 'src/pages/profile/common/ProfileHero/ProfileHero'
    • Recommended for:
      • Feature directories (pages/profile/index.ts)
      • Common utilities and hooks (common/hooks/index.ts)
      • Nested components (pages/profile/common/ProfileHero/index.ts)
    • index.ts files can improve import readability, but excessive use of barrel files can introduce problems such as unintended re-exports, circular dependencies, and inefficient bundling. This is especially relevant in SSR frameworks like Remix.

πŸ”Ή Barrel Files & Wildcard Imports

Using barrel files (index.ts) can simplify imports and improve readability, but they should be used with caution. Overuse can lead to unintended re-exports, circular dependencies, and performance issues in certain frameworks like Remix.

When to Use Barrel Files

  • βœ… When grouping related exports within a feature (pages/profile/index.ts).
  • βœ… When creating a clean API for shared utilities (common/hooks/index.ts).
  • βœ… When improving import readability by reducing deep paths.

When to Avoid Barrel Files

  • ❌ If the file contains a large number of exports, making tree-shaking less effective.
  • ❌ If the file is frequently updated, causing unnecessary rebuilds.
  • ❌ When using Remix and other SSR frameworks, as barrel files can cause issues with bundling.
  • ❌ While index.ts files help simplify imports, overusing them as barrel files (re-exporting everything) can lead to unintended re-exports, circular dependencies, and inefficient bundling. This is particularly problematic in SSR frameworks like Remix, where improper tree-shaking can increase load times.

Best Practices

  • Prefer explicit imports over import * as X to avoid unintended re-exports.
  • Keep barrel files feature-scoped rather than app-wide.
  • Be mindful of how tree-shaking works in the bundler to avoid unnecessary imports.

❌ Avoid wildcard (*) imports as they increase bundle size, prevent effective tree-shaking, and can introduce unnecessary dependencies.

  • Import unnecessary code, even if unused.
  • Make code harder to track, increasing debugging complexity.
import * as utils from β€˜common/utils’

βœ… Prefer named imports for clarity and tree-shaking

import { formatDate, getUserProfile } from β€˜common/utils’

app/
β”œβ”€β”€ routes/
β”œβ”€β”€ src/
β”œβ”€β”€ assets/
β”‚   β”œβ”€β”€ fonts/
β”‚   β”‚   β”œβ”€β”€ roboto-bold.woff
β”‚   β”‚   β”œβ”€β”€ roboto-bold.woff2
β”œβ”€β”€ common/
β”‚   β”œβ”€β”€ components/
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”œβ”€β”€ useFlag.ts
β”œβ”€β”€ config/                      # External service integrations only
β”‚   β”œβ”€β”€ analytics/
β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”œβ”€β”€ useLucencyNumber.ts
β”‚   β”œβ”€β”€ apollo/
β”‚   β”‚   β”œβ”€β”€ ApolloProvider.tsx
β”‚   β”‚   β”œβ”€β”€ index.ts
β”œβ”€β”€ constants/                    # Global constants/utils (if used in multiple features)
β”‚   β”œβ”€β”€ guideUtils.ts             # Example: Used in multiple features
β”‚   β”œβ”€β”€ index.ts
β”‚   β”œβ”€β”€ user.ts
β”œβ”€β”€ pages/
β”‚   β”œβ”€β”€ guide/
β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   β”œβ”€β”€ __mocks__/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ guideMock.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ Guide.test.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ guideUtils.test.ts
β”‚   β”‚   β”œβ”€β”€ common/
β”‚   β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ GuideBadge.test.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ GuideHero/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ GuideHero.test.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ GuideHero.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ GuideHeroLoading.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ GuideBadge.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ GuideLoading.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useCreateGuideMutation.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useGetGuideQuery.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useUpdateGuideMutation.ts
β”‚   β”‚   β”œβ”€β”€ Guide.tsx
β”‚   β”‚   β”œβ”€β”€ index.ts               # For cleaner imports
β”‚   β”‚   β”œβ”€β”€ guideConstants.ts (if needed)
β”‚   β”‚   β”œβ”€β”€ guideUtils.ts (if needed)
β”‚   β”‚   β”œβ”€β”€ types.ts (if needed)
β”‚   β”œβ”€β”€ profile/
β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   β”œβ”€β”€ __mocks__/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ profileMock.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ Profile.test.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ profileUtils.test.ts
β”‚   β”‚   β”œβ”€β”€ common/
β”‚   β”‚   β”‚   β”œβ”€β”€ __tests__/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileHero.test.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileHero/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileHero.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileHeroLoading.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileLoading.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileSidebar/
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileSidebar.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ ProfileSidebarLoading.tsx
β”‚   β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   β”œβ”€β”€ index.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useCreateProfileMutation.ts
β”‚   β”‚   β”‚   β”œβ”€β”€ useGetProfileQuery.ts
β”‚   β”‚   β”œβ”€β”€ Profile.tsx
β”‚   β”‚   β”œβ”€β”€ index.ts               # For cleaner imports
β”‚   β”‚   β”œβ”€β”€ profileConstants.ts (if needed)
β”‚   β”‚   β”œβ”€β”€ profileUtils.ts (if needed)
β”‚   β”‚   β”œβ”€β”€ types.ts (if needed)

πŸ”Ή Why This Works

  • βœ… Scalability β†’ Features remain self-contained, making it easy to expand the app.
  • βœ… Encapsulation β†’ Keeps related files together, reducing unnecessary dependencies.
  • βœ… Readability β†’ Developers can quickly find what they need without deep nesting.
  • βœ… Predictability β†’ Standardized naming and placement eliminate confusion.

🎭 Component Structure

A well-structured React component improves readability, maintainability, and consistency. This section defines how components should be structured, including ordering hooks, variables, functions, and the return statement.

πŸ”Ή General Rules for Components

  • Always use functional components (const MyComponent = () => {}).
  • Component file names should match the folder name if applicable.
    • Example: ProfileHero.tsx inside ProfileHero/ should match the folder name.
  • Each component should be self-contained and only handle one responsibility.
  • Avoid deep nesting of JSXβ€”break into smaller components when necessary.
  • Keep components under ~150 lines for readability.
  • Early return for loading/error states to reduce indentation.
  • Hooks, variables, and functions should follow a consistent order.

πŸ”Ή Component Order

Components should follow this order:

  • 1️⃣ Hooks (useState, useEffect, etc.).
  • 2️⃣ Variables that are not functions (local variables, constants, etc.).
  • 3️⃣ useEffect hooks (side effects).
  • 4️⃣ Functions (event handlers, derived functions, etc.).
  • 5️⃣ Return statement (JSX).

βœ… Example: Standard Component Structure

export const Profile = () => {
  const navigate = useNavigate()
  const { accountHandle } = useParams()
  const { hasError, isLoading, profileData } = useGetProfileQuery(accountHandle)
  const [searchParams] = useSearchParams()
  const { id, image } = profileData ?? {}

  useEffect(() => {
    // Example: Track analytics
  }, [])

  const getProfileAvatar = () => {}

  const getProfileName = () => {}

  if (isLoading || isEmpty(profileData)) return <ProfileLoading />

  if (hasError) return <ProfileEmpty />

  return (
    <section>
      <ProfileHero />
      <div>
        <ProfileSidebar />
        <ProfileContent />
      </div>
    </section>
  )
}

πŸ”Ή Return Statement Spacing

  • Always add a blank line before return to visually separate logic from JSX.
  • This improves readability and scanning by making the function’s return statement stand out.
  • It helps maintain consistency across the codebase.

βœ… Example:

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()

  if (isLoading || isEmpty(profileData)) return <ProfileLoading />

  if (hasError) return <ProfileEmpty />

  return (
    <section>
      <ProfileHero />
      <div>
        <ProfileSidebar />
        <ProfileContent />
      </div>
    </section>
  )
}

❌ Avoid cramming return right after logic without spacing.

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()
  if (isLoading || isEmpty(profileData)) return <ProfileLoading />
  if (hasError) return <ProfileEmpty />
  return (
    <section>
      <ProfileHero />
      <div>
        <ProfileSidebar />
        <ProfileContent />
      </div>
    </section>
  )
}
export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()
  return (
    <section>
      <ProfileHero />
      <div>
        <ProfileSidebar />
        <ProfileContent />
      </div>
    </section>
  )
}

πŸ”Ή Return Formatting in Functional Components

When returning JSX in functional components, maintain consistent spacing for clarity and readability.

βœ… General Rules:

  • Use early returns for loading and error states to reduce nesting.
  • Single-line early returns should not have extra space before them.
  • Multiline return blocks should always be formatted for readability.
  • Return statements should not have an unnecessary empty line before them unless inside a conditional block.

βœ… Example: Correct Formatting

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()

  if (isLoading) return <ProfileLoading />

  if (hasError) return <ProfileError />

  return (
    <section>
      <ProfileHero />
      <ProfileContent />
    </section>
  )
}

❌ Example: Incorrect Formatting

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()

  if (isLoading) {
    return <ProfileLoading />
  }

  if (hasError) {
    return <ProfileEmpty />
  }

  return (
    <section>
      <ProfileHero />
      <ProfileContent />
    </section>
  )
}

βœ… Summary

  • One-liner early returns should not have extra space.
  • Multiline return blocks should always be formatted for readability.
  • Use separate lines when return statements are inside a block.

πŸ”Ή Early Returns for Simplicity

To improve readability and reduce indentation, always return early in conditionals rather than nesting them inside larger blocks.

βœ… Example: Using Early Return for Cleaner Code

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()

  if (isLoading) return <ProfileLoading />

  if (hasError) return <ProfileEmpty />

  return (
    <section>
      <ProfileHero />
      <ProfileContent />
    </section>
  )
}

❌ Example: Nested Conditionals (Harder to Read)

export const Profile = () => {
  const { hasError, isLoading, profileData } = useGetProfileQuery()

  if (isLoading) {
    return <ProfileLoading />
  } else {
    if (hasError) {
      return <ProfileEmpty />
    } else {
      return (
        <section>
          <ProfileHero />
          <ProfileContent />
        </section>
      )
    }
  }
}

πŸ”Ή JSX Formatting Rules

  • One-line return when there is no logic.
export const Profile = () => <section>...</section>
  • Use multiple lines for JSX if it improves readability.
export const Profile = () => (
  <section>
    <ProfileHero />
    <ProfileSidebar />
  </section>
)

πŸ”Ή Function & Hook Spacing Rules

  • No extra space between hooks and variables.
const navigate = useNavigate()
const { accountHandle } = useParams()
const { hasError, isLoading, profileData } = useGetProfileQuery(accountHandle)
const [searchParams] = useSearchParams()
const { id, image } = profileData ?? {}
  • Add a space between function declarations for readability.
const getProfileAvatar = () => {}

const getProfileName = () => {}
  • Space out useEffect from other hooks.
const navigate = useNavigate()
const { accountHandle } = useParams()

useEffect(() => {
  // Example: Sync data on mount
}, [])

πŸ”Ή Component Naming Conventions

  • Use PascalCase for component names.
export const ProfileHero = () => <div>Profile Hero</div>
  • Use camelCase for non-component functions.
const getProfileName = () => {}

πŸ”Ή Loading & Empty State Components

  • Loading states should mirror the component structure but with skeleton placeholders.
  • A ProfileLoading.tsx should match Profile.tsx and replace dynamic content with skeletons.
export const Profile = () => (
  <section className='bg-red'>
    <ProfileHero />
    <div>
      <ProfileSidebar />
      <ProfileContent />
      <Button>Click me</Button>
    </div>
  </section>
)
export const ProfileLoading = () => (
  <section className='bg-red'>
    <ProfileHeroLoading />
    <div>
      <ProfileSidebarLoading />
      <ProfileContentLoading />
      <div className='h-12 w-20'>
        <Skeleton variant='rounded' />
      </div>
    </div>
  </section>
)

πŸ”Ή When to Split Components?

A component should be split into smaller components if:

  • βœ… It exceeds 150 lines.
  • βœ… It handles multiple responsibilities (e.g., UI and state logic).
  • βœ… It contains deeply nested JSX.
  • βœ… It repeats similar JSX structures that could be reused.

πŸ”Ή When to Use a common/ Component?

  • If a component is reused across multiple features, move it to common/components/.
  • If a component is only used within one feature, keep it inside that feature's folder.

βœ… Example (Feature-Specific Component)

pages/profile/common/ProfileHero.tsx

βœ… Example (Shared Component)

common/components/ImageWithFallback.tsx

πŸ”Ή Why This Works

  • βœ… Keeps component logic predictable and structured.
  • βœ… Encourages clean, readable JSX formatting.
  • βœ… Prevents unnecessarily large components.
  • βœ… Standardizes naming and file placement across the codebase.

⚑ Functions & Utilities

This section defines where and how utility functions should be structured to ensure readability and maintainability.


πŸ”Ή Utility Function Placement

  • Feature-specific utilities should be inside a feature’s folder.
  • Shared utilities across multiple features should be moved to constants/featureUtils.ts.

βœ… Example: Utility Function Placement

pages/profile/profileUtils.ts # Feature-specific utilities
constants/userUtils.ts # Shared utilities across features

βœ… Example: Exporting Multiple Utilities

const getProfileAvatar = () => {}

const getProfileName = () => {}

export { getProfileAvatar, getProfileName }

πŸ”Ή Formatting Rules for Functions

  • Avoid unnecessary function nesting β†’ Functions should be flat and readable, avoiding deeply nested logic.

❌ Bad Example (Unnecessary Nesting)

const getUserDetails = user => {
  if (user) {
    return {
      id: user.id,
      name: user.name,
      email: user.email,
    }
  } else {
    return null
  }
}

βœ… Good Example (Flat and Readable)

const getUserDetails = user => {
  if (!user) return null

  return {
    id: user.id,
    name: user.name,
    email: user.email,
  }
}

πŸ”Ή Return Placement in Functions

Return statements inside functions follow consistent spacing rules for readability.

βœ… General Rules

  • If an if statement is at the start of the function, do not add a blank line before it.
  • If an if statement appears in the middle of the function, add a blank line before it.
  • If an if statement contains multiple lines, place the return on its own line.
  • Single-line early returns should remain inline unless additional logic is present.
  • Do not add an extra blank line before the final return in a function.

βœ… Example: Early return at the start of the function (no blank line)

const getProfileRole = (profileData: ProfileData) => {
  if (!profileData?.id) {
    console.warn('Profile data is missing ID')
    return 'Guest'
  }

  return profileData.role
}

βœ… Example: Single-line early return (no extra space needed)

const getProfileRole = (profileData: ProfileData) => {
  if (!profileData?.id) return 'Guest'

  return profileData.role
}

βœ… Example: Returning directly in a function with no logic

const getProfileName = (profileData: ProfileData) => `${profileData.firstName} ${profileData.lastName}`

βœ… Example: if appears in the middle of the function (needs a blank line before it)

const getProfileName = (profileData: ProfileData) => {
  const { firstName, lastName } = profileData ?? {}

  if (!firstName || !lastName) return 'Guest'

  return `${firstName} ${lastName}`
}

❌ Example: Missing space before if when it’s in the middle of the function

const getProfileName = (profileData: ProfileData) => {
  const { firstName, lastName } = profileData ?? {}
  if (!firstName || !lastName) return 'Guest'

  return `${firstName} ${lastName}`
}

❌ Example: Extra blank line before a return when it’s the only statement

const getProfileName = (profileData: ProfileData) => {

  return `${profileData.firstName} ${profileData.lastName}`
}

❌ Example: Extra blank line before an early return at the start of a function

const getProfileName = (profileData: ProfileData) => {

  if (!firstName || !lastName) return 'Guest'

  return `${firstName} ${lastName}`
}

❌ Example: Single-line early return should stay inline

const getProfileRole = (profileData: ProfileData) => {
  if (!profileData?.id) { return 'Guest' }
}
const getProfileRole = (profileData: ProfileData) => {
  if (!profileData?.id) {
    return 'Guest'
  }
}

πŸ”Ή Summary of Return Placement Rules

Case Blank Line Before Return?
Single return as the only function statement ❌ No
Early return at the start of a function ❌ No
if appears in the middle of the function βœ… Yes
Final return in a function ❌ No
Return inside a multi-line if block βœ… Yes

πŸ”₯ Final Thoughts

Return placement follows the same logic as variables:

  • If an if appears in the middle of a function, add a blank line before it.
  • If an if is at the start of a function, no blank line is needed.
  • A multi-line if block always places the return on its own line.
  • A single-line early return remains inline unless there’s additional logic.

By keeping returns structured and predictable, code stays clean, readable, and consistent across the project. πŸš€


πŸ”Ή Why This Works

  • βœ… Keeps the focus on utility function placement & formatting.
  • βœ… Removes redundancy with Component Structure.
  • βœ… Ensures consistent utility function placement across the project.

πŸ“‘ GraphQL Queries

A structured approach to handling GraphQL queries and mutations ensures readability, maintainability, and consistency across the application.

πŸ”Ή General Rules for GraphQL Queries & Mutations

  • Queries & Mutations should be placed in hooks/ inside their respective feature folder

βœ… Example:

src/pages/profile/hooks/useGetProfileQuery.ts # Feature-specific query
src/pages/profile/hooks/useCreateProfileMutation.ts # Feature-specific mutation
src/hooks/useGetPredefinedGuideTagsQuery.ts # Sitewide query (used across features)
  • Use camelCase for variables inside GraphQL operations to maintain consistency with JavaScript/TypeScript naming conventions.
  • Operation name should be based on the data being fetched/updated, ensuring consistency with file & function names.

βœ… Example:

query GetProfileQueryInProfile($id: ID!) { ... }
  • Sort fields alphabetically, except for id, which should always be listed first as the primary identifier for consistency and quick reference.
  • GraphQL fields should match the query name for clarity.
  • For sitewide queries, the operation name should remain generic and should not include In{featureName}.

πŸ”Ή Feature-Based Queries vs. Sitewide Queries

To differentiate feature-specific GraphQL queries/mutations from global queries, we use a structured naming convention:

Feature-Based Queries & Mutations

  • Feature-specific queries & mutations should include In{featureName} in the operation name to differentiate them from sitewide queries and avoid naming conflicts.
  • File Placement: Should be placed within the feature folder inside pages/featureName/.

βœ… Example:

src/pages/profile/hooks/useGetProfileQuery.ts # Query used only in Profile
src/pages/profile/hooks/useUpdateProfileMutation.ts # Mutation used only in Profile

βœ… Query Example:

query GetProfileQueryInProfile($id: ID!) {
  node(id: $id) {
    ... on Profile {
      id
      accountHandle
      displayName
      image
    }
  }
}

Sitewide Queries & Mutations

  • Queries that are used across multiple features should not include the feature name in their operation.
  • File Placement: These should be placed in src/hooks/.

βœ… Example:

src/hooks/useGetPredefinedGuideTagsQuery.ts # Sitewide query

βœ… Query Example:

query GetPredefinedGuideTags {
  predefinedGuideTags {
    id
    name
  }
}

πŸ”Ή Why This Naming Works

  • Feature-Based Queries Include Feature Name
    • Queries scoped to a feature include In{featureName} (e.g., GetProfileQueryInProfile) to prevent name collisions.
    • This ensures clarity when multiple queries exist under the same feature.
  • Sitewide Queries Should Remain Generic
    • If a query is used across multiple features, it should not include the feature name.
    • This prevents unnecessary feature-specific naming for shared resources.
  • Why We Avoid β€œQueryQuery”
    • If a query is called GetPredefinedGuideTagsQuery, the auto-generated type would be GetPredefinedGuideTagsQueryQuery, which is redundant.
    • By naming the file useGetPredefinedGuideTagsQuery.ts and using the operation name GetPredefinedGuideTags, we avoid the unnecessary duplication.

πŸ“Œ Key Takeaways:

  • If a query/mutation belongs to a single feature, its operation should include the feature name (e.g., GetProfileQueryInProfile, UpdateProfileMutationInProfile).
  • If a query/mutation is used across multiple features, its operation name should not include the feature name (e.g., GetPredefinedGuideTags, UpdateUserSettingsMutation).
  • Feature-based queries & mutations should be placed inside pages/featureName/.
  • Sitewide queries & mutations should be placed in src/hooks/.
  • Mutations should always include β€˜Mutation’ in both the GraphQL operation name and the filename (e.g., useUpdateProfileMutation.ts). Feature-based mutations follow the same In{featureName} rule as queries unless they are sitewide.
    • βœ… Example: useUpdateProfileMutation.ts
  • Feature mutations follow the same naming rule as feature queries, including In{featureName} unless they are sitewide.
  • We avoid β€œQueryQuery” in auto-generated types by keeping the operation name clean.
  • We use PascalCase for hook return types, following Use{QueryName}Result (e.g., UseGetProfileQueryResult).

πŸ”Ή Example: Query for Fetching Profile Data

import { gql, useQuery } from '@apollo/client'

type UseGetProfileQueryResult = {
  hasError: ApolloError
  isLoading: boolean
  profileData: Extract<
    GetProfileQueryInProfileQuery['node'],
    {
      __typename?: 'Profile'
    }
  >
}

const profileQuery = gql(`
  query GetProfileQueryInProfile($id: ID!) {
    node (id: $id) {
      ... on Profile {
        id
        accountHandle
        displayName
        image
      }
    }
  }
`)

export const useGetProfileQuery = (id: string): UseGetProfileQueryResult => {
  const {
    data,
    error: hasError,
    loading: isLoading,
  } = useQuery(profileQuery, {
    variables: {
      id,
    },
  })

  return {
    hasError,
    isLoading,
    profileData: data?.node,
  }
}

πŸ”Ή Why This Works

  • βœ… CamelCase is used for variables (accountHandle, displayName, etc.).
  • βœ… id is prominently placed at the top for consistency.
  • βœ… Query follows a predictable naming structure (GetProfileQueryInProfile).
  • βœ… Custom hook abstracts error and loading states for better readability (hasError, isLoading).

πŸ”Ή Example: Mutation for Updating Profile

import { gql, useMutation } from '@apollo/client'

const updateProfileMutation = gql(`
  mutation UpdateProfileMutationInProfile($updateProfileInput: UpdateProfileInput!) {
    updateProfile(updateProfileInput: $updateProfileInput) {
      id
      displayName
    }
  }
`)

export const useUpdateProfileMutation = () => useMutation(updateProfileMutation)
export const ProfileForm = () => {
  const [updateProfile, updateProfileResult] = useUpdateProfileMutation()

  const onSubmit = async (id: string, displayName: string) => {
    try {
      await updateProfile({
        variables: {
          updateProfileInput: {
            displayName,
            id,
          },
        },
      })
    } catch (error) {
      console.error('Failed to update profile', error)
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <button type='submit'>Update Profile</button>
    </form>
  )
}

πŸ”Ή Why This Works

  • βœ… Mutation follows the naming pattern (UpdateProfileMutationInProfile).
  • βœ… Refetching the profile query ensures UI consistency.
  • βœ… Error and loading states are aliased as hasError and isLoading for better readability.

🚩 Feature Flags

Feature flags enable us to conditionally enable or disable features without deploying new code. This approach allows for progressive rollouts, A/B testing, and safe feature releases.

πŸ”Ή General Structure

Feature flags are managed using two primary components:

  1. Feature Flags Configuration (featureFlags.ts)

    • This file defines all available feature flags.
    • Flags are stored as a record of boolean values.
  2. Feature Flag Hook (useFlag.ts)

    • A custom hook to read feature flag values.
    • Uses local storage overrides, allowing developers to toggle features locally.

πŸ“‚ File Structure

src/
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ feature-flags/
β”‚   β”‚   β”œβ”€β”€ featureFlags.ts    # Central feature flag configuration
β”œβ”€β”€ common/
β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”œβ”€β”€ useFlag.ts         # Hook to check feature flag status

πŸ”Ή Feature Flags Configuration

Feature flags are centrally defined in src/config/feature-flags/featureFlags.ts. This ensures all available flags are explicitly listed.

βœ… Example: Defining Feature Flags

// src/config/feature-flags/featureFlags.ts
type FeatureFlagNames = 'profileHeroV2' | 'profileV2'

const featureFlags: Record<FeatureFlagNames, boolean> = {
  profileHeroV2: false,
  profileV2: false,
}

export type { FeatureFlagNames }
export { featureFlags }

πŸ”Ή Accessing Feature Flags with useFlag

The useFlag hook retrieves the current state of a feature flag, checking for local storage overrides.

βœ… Example: Feature Flag Hook

// src/common/hooks/useFlag.ts
import { useState, useEffect } from 'react'
import type { FeatureFlagNames } from 'src/config/feature-flags/featureFlags'
import { useLocalStorageFlags } from './useLocalStorageFlags'

export const useFlag = (flagKey: FeatureFlagNames | string): boolean => {
  const [isFlagEnabled, setIsFlagEnabled] = useState(false)
  const [localFlags] = useLocalStorageFlags()

  useEffect(() => {
    if (flagKey in localFlags) {
      const { [flagKey]: localStorageFlag } = localFlags
      setIsFlagEnabled(String(localStorageFlag).toLowerCase() === 'true')
    }
  }, [flagKey, localFlags])

  return isFlagEnabled
}

πŸ”Ή Using Feature Flags in Components

βœ… Example: Conditionally Rendering Components

Feature flags allow conditional rendering of components within a section.

import { useFlag } from 'src/common/hooks/useFlag'
import { ProfileHero } from './ProfileHero'
import { ProfileHeroOld } from './ProfileHeroOld'

export const Profile = () => {
  const isProfileHeroV2Enabled = useFlag('profileHeroV2')

  return (
    <section>
      {isProfileHeroV2Enabled ? <ProfileHero /> : <ProfileHeroOld />}
    </section>
  )
}

πŸ”Ή Using Feature Flags for Route-Based Feature Toggles

For larger changes, such as enabling an entirely new Profile redesign, we rename the existing feature folder (profile) to profile-old and introduce a new profile/ folder.

Then, in PageRoutes.tsx, we dynamically choose which version of Profile to render based on the feature flag.

βœ… Example: Routing Feature Flag Usage

import { useFlag } from 'src/common/hooks/useFlag'
import { Routes, Route } from 'react-router-dom'
import { Home } from 'src/pages/home'
import { Profile } from 'src/pages/profile'
import { ProfileOld } from 'src/pages/profile-old'

export const PageRoutes = () => {
  const isProfileV2Enabled = useFlag('profileV2')

  return (
    <ScrollToTop>
      <Routes>
        <Route element={<Home />} path='/' />
        <Route
          element={isProfileV2Enabled ? <Profile /> : <ProfileOld />}
          path='/profile/:accountHandle'
        />
      </Routes>
    </ScrollToTop>
  )
}

πŸ”Ή Feature Flag Guidelines

  • Feature flags should be short-lived
    • Avoid leaving feature flags in the codebase for an extended period.
    • Flags should be removed once the feature is stable.
  • New feature flags must be added to featureFlags.ts
    • This ensures visibility and prevents unexpected feature toggles.
  • Use feature flags only for meaningful toggles
    • Avoid flagging trivial UI changes.
    • Flags should be used for significant features, redesigns, or experiments.
  • Local storage overrides take precedence
    • Developers can manually toggle flags via local storage, making testing easier.

βœ… Summary

  • Feature flags are stored in src/config/feature-flags/featureFlags.ts.
  • The useFlag hook checks feature flag values, including local storage overrides.
  • Flags can be used for component toggles (ProfileHeroV2) or route-based toggles (ProfileV2).
  • Short-lived flags should be cleaned up after rollout is complete.

πŸ”  Types & Interfaces

A consistent approach to defining types and interfaces ensures clarity, maintainability, and flexibility across the codebase.


πŸ”Ή General Rules

  • Use interface for functional components.

    • Interfaces provide better readability when defining props for components.
    • Ensures a clear contract for component usage.
    • Supports extending when needed.
  • Use type for everything else.

    • type provides better flexibility, particularly when defining utility types, hooks, function return values, and GraphQL queries.
    • Use Extract<> when working with GraphQL queries that return multiple types, ensuring type safety while extracting a specific expected type from a union.
  • Use Pick<> and Omit<> to create subsets of types.

    • Pick<> is used when selecting only specific properties from a type.
    • Omit<> is used when removing specific properties from a type.

πŸ”Ή Component Props: Use interface

βœ… Example: Functional Component Props

interface ProfileHeroProps {
  onClick: () => void
  title: string
}

export const ProfileHero = ({ onClick, title }: ProfileHeroProps) => (
  <div onClick={onClick}>{title}</div>
)

βœ… Example: Extending an Interface

Use interface to extend props cleanly, while type uses & for merging multiple types.

import { Button } from '@travelpass/design-system'
import type { GenericAddress } from 'src/__generated__/graphql'

interface ProfileAddressProps extends GenericAddress {
  onClick: VoidFunction
}

export const ProfileAddress = ({
  addressLine1,
  city,
  country,
  onClick,
}: ProfileAddressProps) => (
  <section>
    <h2>{name}</h2>
    <p>{getAddress(addressLine1, city, country)}</p>
    <Button onClick={onClick}>Edit</Button>
  </section>
)

πŸ”Ή Utility Types: Use type

Use Pick<> when selecting only specific properties from a type, and Omit<> when removing specific properties.

These help create lightweight, flexible types for better reusability.

βœ… Example: Utility Type for Query Results

type UseGetProfileQueryResult = {
  hasError: ApolloError
  isLoading: boolean
  profileData: Extract<
    GetProfileQueryInProfileQuery['node'],
    {
      __typename?: 'Profile'
    }
  >
}

βœ… Example: Extracting Only Specific Keys from an Object

type UserKeys = 'id' | 'email'

type UserInfo = Pick<User, UserKeys>

βœ… Example: Omitting Unnecessary Fields from an Object

type User = {
  id: string
  email: string
  password: string
}

type PublicUser = Omit<User, 'password'>

βœ… Example: Combining Multiple Types

Use & to merge multiple types, providing more flexibility than interface extension.

type Base = {
  createdAt: string
}

type Profile = {
  id: string
  name: string
}

type ProfileWithBase = Profile & Base

πŸ”Ή When to Use Extract<> in GraphQL

  • Use Extract<> to ensure type safety when selecting a specific type from a GraphQL query result.

βœ… Example: Extracting the Profile Type from a Query

type UseGetProfileQueryResult = {
  hasError: ApolloError
  isLoading: boolean
  profileData: Extract<
    GetProfileQueryInProfileQuery['node'],
    {
      __typename?: 'Profile'
    }
  >
}

πŸ”Ή Avoid Unnecessary interface Usage

❌ Bad Example: Using interface for Utility Types

interface UseGetProfileQueryResult {
  hasError: ApolloError
  isLoading: boolean
  profileData: Profile
}

βœ… Good Example: Using type for Flexibility

type UseGetProfileQueryResult = {
  hasError: ApolloError
  isLoading: boolean
  profileData: Profile
}

βœ… Summary

  • Use interface for defining props in functional components.
  • Use type for everything else (utilities, hooks, GraphQL queries, API responses).
  • Use Extract<> to ensure type safety when narrowing GraphQL query results.
  • Keep types minimal and readableβ€”avoid unnecessary abstractions.

πŸ“ Comments & Documentation

A minimalist approach to comments ensures code is clean, readable, and self-explanatory. Instead of excessive commenting, we prioritize descriptive function and variable names. Comments are used only when necessary, such as for complex logic, workarounds, or TODOs.


πŸ”Ή General Rules

  • Favor meaningful variable and function names over comments.

    • Code should explain itself rather than rely on comments.
    • If logic is unclear, refactor instead of adding a comment.
  • Use JSDoc (@see) when the workaround requires a reference link, external documentation, or detailed explanation.

    • JSDoc ensures proper referencing in documentation tools like TypeDoc.
    • Example:
      /**
       * Safari requires a slight delay for smooth scrolling.
       * @see https://stackoverflow.com/q/xxxx
       */
  • Use JSDoc (@todo) for marking future work.

    • We use @todo in JSDoc sparingly for tracking unfinished tasks.
    • If a task has a corresponding Linear issue, consider referencing it using @todo Linear-123.
      • Example: /** @todo TMNT-123 Update this when the new API version is available */
    • JSDoc comments should be above the function or logic they reference.
    • Prefer compact, one-line comments whenever possible.
  • Use inline // comments for workarounds or technical limitations.

    • These should be short and placed directly above the relevant line.
  • Avoid excessive commenting.

    • Only document "why" something is done, not "what" the code does.

πŸ”Ή Using JSDoc for TODOs

We only use JSDoc (/** @todo */) for tracking future work.

βœ… Example: JSDoc TODO for Future Enhancements

/** @todo Update this when the new API version is available */
const getUserPreferences = async (userId: string) => {
  try {
    return await fetch(`/api/preferences/${userId}`)
  } catch (error) {
    console.error(error)
    return null
  }
}

❌ Avoid Unnecessary TODO Comments

This format is not compatible with JSDoc linters.

// @todo Update this when the new API version is available
const getUserPreferences = async (userId: string) => {
  try {
    return await fetch(`/api/preferences/${userId}`)
  } catch (error) {
    console.error(error)
    return null
  }
}

πŸ’‘ Key Difference:

  • βœ… Use @todo for JSDoc-style TODOs.
  • ❌ Avoid inline // @todo comments.

JSDoc is more structured and aligns with tools that scan TODOs.


πŸ”Ή Inline Comments for Workarounds

Use inline // comments for technical workarounds, browser quirks, or unexpected API behavior.

βœ… Example: Workaround for Safari Quirk

const scrollToTop = () => {
  window.scrollTo(0, 0)
  // Safari requires a slight delay for smooth scrolling
  setTimeout(() => window.scrollTo(0, 0), 10)
}

βœ… Example: Workaround for Safari Quirk with @see

/**
 * Safari requires a slight delay for smooth scrolling.
 * @see https://stackoverflow.com/q/xxxx
 */
const scrollToTop = () => {
  window.scrollTo(0, 0)
  setTimeout(() => window.scrollTo(0, 0), 10)
}

❌ Avoid Redundant Comments

const scrollToTop = () => {
  // Scrolls to the top of the page
  window.scrollTo(0, 0)
}

πŸ’‘ Key Difference:

  • βœ… Comments should explain β€œwhy” a workaround is needed.
  • ❌ Avoid stating what the code already makes obvious.

πŸ”Ή Handling Complex useEffect Hooks

For useEffect, prefer extracting logic into functions instead of writing comments inline.

βœ… Example: Extracting Logic Into a Function

useEffect(() => {
  syncUserPreferences()
}, [])

const syncUserPreferences = async () => {
  try {
    /** @todo Remove this workaround when the API provides real-time updates */
    const preferences = await getUserPreferences(user.id)
    applyUserPreferences(preferences)
  } catch (error) {
    console.error(error)
  }
}

❌ Example of an Overloaded useEffect with Comments

useEffect(() => {
  // Fetch user preferences and apply them
  fetch(`/api/preferences/${user.id}`)
    .then(res => res.json())
    .then(preferences => {
      // Apply user preferences
      applyUserPreferences(preferences)
    })
}, [])

πŸ’‘ Key Takeaway:

  • βœ… Extract logic into functions rather than writing inline comments in useEffect.
  • ❌ Long comments inside useEffect add clutter.

πŸ”Ή When to Write a Comment vs. Refactor?

Before writing a comment, ask:

  • Is the function name clear enough?
    • If no, rename the function instead of adding a comment.
  • Is this logic unavoidable or non-obvious?
    • If yes, add an inline comment.
  • Is this a workaround for a browser quirk or API limitation?
    • If yes, a comment is useful.

βœ… Summary

  • Avoid unnecessary commentsβ€”favor meaningful variable & function names.
  • Use JSDoc @todo for tracking future work.
  • Use inline // comments only for workarounds or unexpected behavior.
  • Refactor first, comment as a last resort.
  • If a useEffect is complex, extract logic into functions instead of writing comments.

🀝 Contributing

Thank you for considering contributing to this project! We appreciate your help in improving and maintaining this repository.


πŸ”Ή How to Contribute

  1. Fork the Repository

    • Click the Fork button on the top right of this repository.
    • This will create a copy under your GitHub account.
  2. Clone Your Fork

    • Run the following command to clone the forked repository:

      git clone https://github.com/YOUR-USERNAME/react-typescript-style-guide.git
      cd react-typescript-style-guide
  3. Make your changes in main

    • Open the project in your preferred editor.
    • Make your changes while following the project’s coding guidelines.
  4. Commit your changes

    git add .
    git commit -m "Describe your changes"
  5. Push to your fork

    git push origin main
  6. Create a Pull Request

    • Go to the original repository on GitHub.
    • Click Compare & pull request.
    • Add a clear description of your changes.
    • Click Create pull request.

πŸ”Ή Contribution Guidelines

  • Keep PRs small and focused

    • If your change is large, consider breaking it into smaller PRs.
  • Follow the existing code style

    • Ensure your code follows formatting and linting rules.
  • Write clear commit messages

    • Use concise descriptions like Fix button alignment issue or Add support for dark mode.
  • Ensure your changes do not break existing functionality

    • Test your changes before submitting.
  • Be respectful and collaborative

    • We appreciate all contributions and encourage constructive feedback.

βœ… Thank you for contributing! We appreciate your support in improving this project. πŸš€


πŸ“œ License

This project is licensed under the MIT License.

You are free to use, modify, distribute, and share this project with no restrictions, as long as the original license and copyright notice are included.

πŸ“„ Full License

The full license text is available in the LICENSE.md file.


πŸ“š References & Inspirations

This style guide follows widely accepted industry standards while maintaining a minimal, structured, and opinionated approach. Below are key resources that align with and support the philosophy, structure, and best practices outlined in this guide.

πŸ“Œ Key Influences on This Guide

Each of the following references shares core principles with this style guide, such as clarity, maintainability, predictability, and reducing complexity.

Reference Link How It Relates
Google TypeScript Style Guide Google TypeScript Guide βœ… Readability & maintainability via consistent naming, structured function ordering, and predictable patterns.
βœ… Aligns with Component Order and Separation of Concerns principles.
Airbnb React/JSX Style Guide Airbnb React Guide βœ… Focuses on self-contained components, logical function ordering, and clean JSX formatting.
βœ… Strongly aligns with Component Structureβ€”especially hooks, variables, and function organization.
Shopify JavaScript & TypeScript Guide Shopify JavaScript & TypeScript Guide βœ… Encourages feature-based folder structure, aligning with Folder Structure.
βœ… Supports encapsulating GraphQL queries within feature folders, similar to our GraphQL Queries section.
TS.dev TypeScript Style Guide TS.dev Guide βœ… Emphasizes clarity and minimalism, reinforcing No Unnecessary Abstraction.
βœ… Aligns with using interfaces for components and types for utilities/hooks.
TypeScript Deep Dive Style Guide TypeScript Deep Dive βœ… Advocates predictable, structured code organization and explicit return types.
βœ… Aligns with Types & Interfaces, particularly Extract<>, Pick<>, and Omit<> usage.

πŸ’‘ Final Thoughts

This style guide follows industry best practices while taking a minimalist approach to ensure scalability, predictability, and maintainability.

By adopting these conventions, you ensure consistency across projects while writing modern, well-structured React + TypeScript code.

πŸš€ Thank you for following this guide! Your contributions help keep codebases clean, readable, and scalable.