Frontend Development

Frontend Architecture: Building Scalable React Applications

·6 min read
Frontend Architecture: Building Scalable React Applications

Frontend Architecture: Building Scalable React Applications

Frontend architecture is crucial for building maintainable and scalable applications. Let's explore essential patterns and best practices for React applications.

Project Structure

1. Feature-Based Architecture

Organize code by features:

// src/features/auth/types/index.ts
export interface User {
  id: string;
  email: string;
  name: string;
  role: UserRole;
}

export enum UserRole {
  ADMIN = "ADMIN",
  USER = "USER",
}

// src/features/auth/api/auth.api.ts
import { api } from "@/lib/api";
import { User } from "../types";

export const authApi = {
  login: async (credentials: LoginCredentials): Promise<AuthResponse> => {
    const response = await api.post("/auth/login", credentials);
    return response.data;
  },

  register: async (userData: RegisterData): Promise<User> => {
    const response = await api.post("/auth/register", userData);
    return response.data;
  },

  getCurrentUser: async (): Promise<User> => {
    const response = await api.get("/auth/me");
    return response.data;
  },
};

// src/features/auth/components/LoginForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "../schemas";
import { useAuth } from "../hooks/useAuth";

export const LoginForm = () => {
  const { login, isLoading } = useAuth();
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm({
    resolver: zodResolver(loginSchema),
  });

  const onSubmit = async (data: LoginFormData) => {
    await login(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <Input
        {...register("email")}
        error={errors.email?.message}
        label="Email"
      />
      <Input
        {...register("password")}
        type="password"
        error={errors.password?.message}
        label="Password"
      />
      <Button type="submit" loading={isLoading}>
        Login
      </Button>
    </form>
  );
};

State Management

1. React Query Configuration

Set up React Query for server state:

// src/lib/react-query.ts
import { QueryClient } from "@tanstack/react-query";

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000,
      cacheTime: 10 * 60 * 1000,
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

// src/features/posts/hooks/usePosts.ts
import { useQuery, useMutation } from "@tanstack/react-query";
import { postsApi } from "../api/posts.api";

export const usePosts = (params: PostsParams) => {
  return useQuery({
    queryKey: ["posts", params],
    queryFn: () => postsApi.getPosts(params),
    keepPreviousData: true,
  });
};

export const useCreatePost = () => {
  return useMutation({
    mutationFn: postsApi.createPost,
    onSuccess: () => {
      queryClient.invalidateQueries(["posts"]);
    },
  });
};

2. Zustand Store

Implement client state management:

// src/stores/theme.store.ts
import create from "zustand";
import { persist } from "zustand/middleware";

interface ThemeState {
  theme: "light" | "dark";
  setTheme: (theme: "light" | "dark") => void;
  toggleTheme: () => void;
}

export const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: "light",
      setTheme: (theme) => set({ theme }),
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === "light" ? "dark" : "light",
        })),
    }),
    {
      name: "theme-storage",
    }
  )
);

// src/stores/auth.store.ts
interface AuthState {
  user: User | null;
  setUser: (user: User | null) => void;
  isAuthenticated: boolean;
}

export const useAuthStore = create<AuthState>((set) => ({
  user: null,
  isAuthenticated: false,
  setUser: (user) =>
    set({
      user,
      isAuthenticated: !!user,
    }),
}));

Component Architecture

1. Component Composition

Implement component composition patterns:

// src/components/layout/AppLayout.tsx
interface AppLayoutProps {
  header?: React.ReactNode;
  sidebar?: React.ReactNode;
  children: React.ReactNode;
  footer?: React.ReactNode;
}

export const AppLayout = ({
  header,
  sidebar,
  children,
  footer,
}: AppLayoutProps) => {
  return (
    <div className="min-h-screen bg-gray-50">
      {header && <header className="sticky top-0 z-10">{header}</header>}
      <div className="flex">
        {sidebar && <aside className="w-64 min-h-screen">{sidebar}</aside>}
        <main className="flex-1 p-6">{children}</main>
      </div>
      {footer && <footer>{footer}</footer>}
    </div>
  );
};

// src/components/data-display/DataTable.tsx
interface DataTableProps<T> {
  data: T[];
  columns: Column<T>[];
  isLoading?: boolean;
  onRowClick?: (item: T) => void;
  renderEmpty?: () => React.ReactNode;
  renderError?: (error: Error) => React.ReactNode;
}

export const DataTable = <T extends object>({
  data,
  columns,
  isLoading,
  onRowClick,
  renderEmpty,
  renderError,
}: DataTableProps<T>) => {
  if (isLoading) {
    return <TableSkeleton columns={columns.length} />;
  }

  if (!data.length && renderEmpty) {
    return renderEmpty();
  }

  return (
    <table className="min-w-full divide-y">
      <thead>
        <tr>
          {columns.map((column) => (
            <th key={column.key}>{column.title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {data.map((item, index) => (
          <tr
            key={index}
            onClick={() => onRowClick?.(item)}
            className={clsx("hover:bg-gray-50", onRowClick && "cursor-pointer")}
          >
            {columns.map((column) => (
              <td key={column.key}>
                {column.render
                  ? column.render(item)
                  : item[column.key as keyof T]}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
};

API Layer

1. API Client Configuration

Set up API client with interceptors:

// src/lib/api.ts
import axios from "axios";
import { toast } from "react-hot-toast";

export const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    "Content-Type": "application/json",
  },
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  (error) => {
    const message = error.response?.data?.message || "An error occurred";

    toast.error(message);

    if (error.response?.status === 401) {
      // Handle unauthorized
    }

    return Promise.reject(error);
  }
);

// src/lib/api-hooks.ts
export const createApiHook = <T, P = void>(
  apiCall: (params: P) => Promise<T>
) => {
  return (params: P) =>
    useQuery({
      queryKey: [apiCall.name, params],
      queryFn: () => apiCall(params),
    });
};

export const createMutationHook = <T, P = void>(
  apiCall: (params: P) => Promise<T>,
  options?: UseMutationOptions<T, Error, P>
) => {
  return () =>
    useMutation({
      mutationFn: apiCall,
      ...options,
    });
};

Performance Optimization

1. Code Splitting

Implement dynamic imports:

// src/pages/dashboard.tsx
import dynamic from "next/dynamic";

const DashboardChart = dynamic(
  () => import("@/features/dashboard/components/DashboardChart"),
  {
    loading: () => <ChartSkeleton />,
    ssr: false,
  }
);

const DashboardStats = dynamic(
  () => import("@/features/dashboard/components/DashboardStats")
);

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <DashboardStats />
      <DashboardChart />
    </div>
  );
}

// src/components/common/Image.tsx
import { useState } from "react";
import NextImage from "next/image";

export const Image = (props: ImageProps) => {
  const [isLoading, setLoading] = useState(true);

  return (
    <div className={clsx("relative", isLoading && "animate-pulse bg-gray-200")}>
      <NextImage {...props} onLoadingComplete={() => setLoading(false)} />
    </div>
  );
};

Error Handling

1. Error Boundary Implementation

Create error boundaries:

// src/components/error/ErrorBoundary.tsx
import { Component, ErrorInfo } from "react";

interface Props {
  children: React.ReactNode;
  fallback?: React.ReactNode;
  onError?: (error: Error, errorInfo: ErrorInfo) => void;
}

interface State {
  hasError: boolean;
  error?: Error;
}

export class ErrorBoundary extends Component<Props, State> {
  state: State = {
    hasError: false,
  };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    this.props.onError?.(error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div className="p-4 bg-red-50 text-red-700">
            <h2>Something went wrong</h2>
            <pre>{this.state.error?.message}</pre>
          </div>
        )
      );
    }

    return this.props.children;
  }
}

// Usage
const App = () => {
  return (
    <ErrorBoundary
      onError={(error, errorInfo) => {
        // Log error to service
      }}
    >
      <AppContent />
    </ErrorBoundary>
  );
};

Best Practices

  1. Feature-Based Structure: Organize code by features
  2. Component Composition: Use composition over inheritance
  3. State Management: Separate server and client state
  4. Performance: Implement code splitting and lazy loading
  5. Error Handling: Use error boundaries and proper error handling
  6. Type Safety: Leverage TypeScript for better type safety
  7. Testing: Write comprehensive tests for components
  8. Documentation: Maintain clear documentation

Implementation Checklist

  1. Set up project structure
  2. Configure state management
  3. Implement component architecture
  4. Set up API layer
  5. Add performance optimizations
  6. Implement error handling
  7. Add TypeScript configurations
  8. Set up testing framework

Conclusion

A well-structured frontend architecture is essential for building maintainable React applications. Focus on implementing these patterns and practices to create scalable applications.

Resources