Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save tom-doerr/360383c0659f79f46fabbbdff9cea1ca to your computer and use it in GitHub Desktop.
Save tom-doerr/360383c0659f79f46fabbbdff9cea1ca to your computer and use it in GitHub Desktop.
Radically Reducing Complexity in React Applications

Radically Reducing Complexity in React Applications

This article was generated by Claude 3.7 Sonnet, an AI assistant from Anthropic, to help developers build more maintainable React applications.

Building complex React applications, especially with AI assistance, can quickly lead to unmanageable code. As applications grow, complexity compounds, making it harder to debug issues, add features, or collaborate with teammates. This article outlines practical strategies to radically reduce complexity in React applications through better component isolation.

Why Isolation Matters

Isolated components are:

  • Easier to test: Clear inputs and outputs
  • Easier to debug: Limited scope of potential issues
  • Easier to reuse: No hidden dependencies
  • Easier to maintain: Changes have limited impact
  • Easier for AI to work with: Clear boundaries minimize context-related errors

Let's explore six powerful strategies for better component isolation.

1. Single Responsibility Principle

Each component should do exactly one thing. This is the foundation of component isolation.

Before isolation:

function ProfileCard({ user, onEdit, onDelete }) {
  return (
    <div className="card">
      <img src={user.avatar} alt={user.name} />
      <h2>{user.name}</h2>
      <p>{user.bio}</p>
      <p>{user.email}</p>
      <div className="actions">
        <button onClick={() => onEdit(user.id)}>Edit</button>
        <button onClick={() => onDelete(user.id)}>Delete</button>
      </div>
    </div>
  );
}

After isolation:

function Avatar({ src, name }) {
  return <img src={src} alt={name} className="avatar" />;
}

function UserInfo({ name, bio, email }) {
  return (
    <div className="user-info">
      <h2>{name}</h2>
      <p>{bio}</p>
      <p>{email}</p>
    </div>
  );
}

function ActionButtons({ userId, onEdit, onDelete }) {
  return (
    <div className="actions">
      <button onClick={() => onEdit(userId)}>Edit</button>
      <button onClick={() => onDelete(userId)}>Delete</button>
    </div>
  );
}

function ProfileCard({ user, onEdit, onDelete }) {
  return (
    <div className="card">
      <Avatar src={user.avatar} name={user.name} />
      <UserInfo name={user.name} bio={user.bio} email={user.email} />
      <ActionButtons userId={user.id} onEdit={onEdit} onDelete={onDelete} />
    </div>
  );
}

By breaking down the component, we now have smaller, purpose-focused components that can be tested, reused, and modified independently.

2. Pure Presentation Components

Create components that are purely presentational—receiving all data via props and communicating only through events. Avoid having components that fetch their own data or interact directly with global state.

// Pure presentation component
function ColorPicker({ colors, selectedColor, onColorSelect }) {
  return (
    <div className="color-picker">
      {colors.map(color => (
        <div
          key={color}
          className={`color-swatch ${selectedColor === color ? 'selected' : ''}`}
          style={{ backgroundColor: color }}
          onClick={() => onColorSelect(color)}
        />
      ))}
    </div>
  );
}

// Usage with default values for testing
function ColorPickerExample() {
  const [selected, setSelected] = useState('#FF0000');
  
  return (
    <ColorPicker
      colors={['#FF0000', '#00FF00', '#0000FF']}
      selectedColor={selected}
      onColorSelect={setSelected}
    />
  );
}

These components are incredibly easy to test because they're predictable: given the same props, they always render the same way.

3. Separate Logic with Custom Hooks

Extract business logic and state management into custom hooks. This keeps your components focused on the UI while moving data handling into reusable, testable hooks.

// Custom hook
function useBannerGenerator(initialConfig = {}) {
  const [config, setConfig] = useState({
    title: '',
    description: '',
    backgroundColor: '#3a1c71',
    ...initialConfig
  });
  
  const updateConfig = useCallback((updates) => {
    setConfig(prev => ({ ...prev, ...updates }));
  }, []);
  
  const generateSvg = useCallback(() => {
    // SVG generation logic...
    const svgString = `<svg>...</svg>`;
    return svgString;
  }, [config]);
  
  return {
    config,
    updateConfig,
    generateSvg
  };
}

// Component using the hook
function BannerGenerator() {
  const { config, updateConfig, generateSvg } = useBannerGenerator();
  
  return (
    <div>
      <input
        value={config.title}
        onChange={(e) => updateConfig({ title: e.target.value })}
        placeholder="Banner Title"
      />
      {/* More inputs for other configuration options */}
      <div dangerouslySetInnerHTML={{ __html: generateSvg() }} />
    </div>
  );
}

This separation allows you to test the business logic independently from the UI. You can verify that useBannerGenerator correctly updates state and generates SVGs without rendering any components.

4. Container/Component Pattern

Separate data fetching and state management from rendering by using container components and presentational components.

// Container component (handles state and side effects)
function UserProfileContainer({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    async function fetchUser() {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    
    fetchUser();
  }, [userId]);
  
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;
  
  return <UserProfile user={user} />;
}

// Presentation component (pure rendering)
function UserProfile({ user }) {
  return (
    <div className="profile">
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
      {/* Other user information */}
    </div>
  );
}

The container component handles all the complex state and side effects, while the presentation component focuses solely on rendering. This makes the UserProfile component easy to test with mock data and reuse in different contexts.

5. Feature-Based Folder Structure

Organize your code by feature rather than by type (components, hooks, utils, etc.). This improves discoverability and indicates which components belong together.

/src
  /features
    /banner-generator
      /components
        ColorPicker.jsx
        ColorPicker.test.jsx
        Preview.jsx
        Preview.test.jsx
      /hooks
        useBannerGenerator.js
        useBannerGenerator.test.js
      /utils
        svgGenerator.js
        svgGenerator.test.js
      index.js  // Exports the main component
    /user-management
      /components
      /hooks
      /utils
      index.js

This structure makes it clear which components are related and helps maintain isolation between features. It also makes it easier for AI tools to understand the context of what you're working on.

6. Co-locate Components and Tests

Placing tests next to the components they test provides several benefits:

  • Immediate visibility of test coverage
  • Tests serve as documentation
  • Easier to update tests when components change
  • Clearer context for AI assistants

Option A: Same file (Component + Test)

// Button.jsx
import React from 'react';

// Component definition
export function Button({ onClick, children, disabled = false }) {
  return (
    <button 
      onClick={onClick}
      disabled={disabled}
      className="button"
    >
      {children}
    </button>
  );
}

// Tests in the same file (if using Jest/React Testing Library)
if (process.env.NODE_ENV !== 'production') {
  const { render, screen, fireEvent } = require('@testing-library/react');
  
  describe('Button', () => {
    it('renders children correctly', () => {
      render(<Button>Click me</Button>);
      expect(screen.getByText('Click me')).toBeInTheDocument();
    });
    
    it('calls onClick when clicked', () => {
      const handleClick = jest.fn();
      render(<Button onClick={handleClick}>Click me</Button>);
      fireEvent.click(screen.getByText('Click me'));
      expect(handleClick).toHaveBeenCalledTimes(1);
    });
    
    it('is disabled when disabled prop is true', () => {
      render(<Button disabled>Click me</Button>);
      expect(screen.getByText('Click me')).toBeDisabled();
    });
  });
}

Option B: Side-by-side files

/components
  /Button
    Button.jsx
    Button.test.jsx
    index.js  // Re-exports Button

Co-location is particularly valuable when working with AI assistants, as it gives them complete context about both the component's implementation and its expected behavior.

7. Mock External Dependencies

Create interfaces for external services and dependencies, making them injectable and mockable.

// Define service interface
export const createGithubService = ({ token }) => ({
  uploadFile: async (owner, repo, path, content) => {
    // Implementation using the GitHub API
    // ...
  },
  updateReadme: async (owner, repo, content) => {
    // Implementation using the GitHub API
    // ...
  }
});

// Component uses the service via props
function BannerUploader({ githubService, bannerSvg, repoInfo }) {
  const [status, setStatus] = useState('idle');
  
  const handleUpload = async () => {
    try {
      setStatus('uploading');
      await githubService.uploadFile(
        repoInfo.owner,
        repoInfo.repo,
        'assets/banner.svg',
        bannerSvg
      );
      setStatus('success');
    } catch (error) {
      setStatus('error');
    }
  };
  
  return (
    <div>
      <button onClick={handleUpload} disabled={status === 'uploading'}>
        Upload to GitHub
      </button>
      {status === 'success' && <p>Upload complete!</p>}
      {status === 'error' && <p>Error uploading banner</p>}
    </div>
  );
}

// In tests, you can easily mock the service
const mockGithubService = {
  uploadFile: jest.fn().mockResolvedValue({ success: true }),
  updateReadme: jest.fn().mockResolvedValue({ success: true })
};

render(
  <BannerUploader
    githubService={mockGithubService}
    bannerSvg="<svg>...</svg>"
    repoInfo={{ owner: 'user', repo: 'repo-name' }}
  />
);

This approach lets you test your components without hitting real APIs and makes it easy to simulate different scenarios (success, error, loading).

Conclusion

Implementing these isolation strategies might seem like more work initially, but they pay significant dividends in maintainability, testability, and overall developer experience. They're especially valuable when working with AI assistants, as they reduce the context needed for the AI to understand your codebase.

By creating clear boundaries between components and focusing each component on a single responsibility, you'll build React applications that are easier to understand, test, and extend—even as they grow in complexity.


This article was generated by Claude 3.7 Sonnet (March 2025 version), an AI assistant from Anthropic. While I've aimed to provide accurate and helpful advice based on best practices in React development, you should always adapt these recommendations to your specific project requirements and team preferences.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment