Javascript November 15, 2025 33 min read RidByte RidByte

Migrating from React to Preact: A Complete Guide

Learn how I reduced my widget bundle from 377KB to 255KB (33% reduction) by migrating from React + Quill to Preact + Pell. Complete step-by-step guide with real-world results, code examples, and migration patterns.


Introduction

When building web widgets or embedded applications, every kilobyte counts. React is a powerful framework, but its bundle size can be prohibitive for performance-critical applications. In this guide, I'll walk you through my journey of migrating from React + Quill to Preact + Pell, reducing my widget bundle from 377KB to 255KB (gzipped) — a 33% reduction (or 1.19MB to 799KB uncompressed).

Why Preact?

Bundle Size Comparison

The numbers speak for themselves:

  • React + ReactDOM: ~140KB (gzipped)
  • Preact: ~4KB (gzipped)
  • Savings: ~136KB (96% smaller)

Performance Benefits

Beyond bundle size, Preact offers:

  • Faster virtual DOM diffing - More efficient reconciliation algorithm
  • Smaller memory footprint - Uses significantly less RAM
  • Faster initial render - Lightweight core leads to quicker startup
  • Better mobile performance - Critical for resource-constrained devices

Compatibility

The best part? Preact maintains nearly 100% React API compatibility:

  • Works with most React libraries out of the box
  • Easy migration path with minimal code changes
  • Compatible with React DevTools via preact/debug
  • Strong TypeScript support

My Migration Journey

My migration was actually a two-part optimization:

Part 1: Switch to Lighter Editor (Quill → Pell)

First, I replaced Quill.js (~140KB) with Pell editor (~1.5KB), which contributed significantly to the bundle reduction.

Part 2: Framework Migration (React → Preact)

Then I migrated from React to Preact, replacing React's ~45KB with Preact's ~3KB.

Combined Result: 377KB → 255KB (gzipped) and 1.19MB → 799KB (uncompressed)

Prerequisites

Before starting your migration, ensure:

  • ✅ Your app has good test coverage
  • ✅ You've documented any React-specific behaviors
  • ✅ Dependencies are up to date
  • ✅ You have a rollback plan

Step-by-Step Migration Guide

Step 1: Install Preact

# Install Preact and compat layer
npm install preact

# Install Vite plugin (if using Vite)
npm install --save-dev @preact/preset-vite

# Remove React
npm uninstall react react-dom

Step 2: Update Build Configuration

The key to a smooth migration is using Preact's compatibility layer to alias React imports.

For Vite (vite.config.js)

import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';

export default defineConfig({
  plugins: [preact()],
  resolve: {
    alias: {
      'react': 'preact/compat',
      'react-dom': 'preact/compat',
    },
  },
});

For Webpack (webpack.config.js)

module.exports = {
  resolve: {
    alias: {
      'react': 'preact/compat',
      'react-dom/test-utils': 'preact/test-utils',
      'react-dom': 'preact/compat',
      'react/jsx-runtime': 'preact/jsx-runtime',
    },
  },
};

Step 3: Update Imports

With the aliases in place, most imports continue to work. However, for optimal bundle size, update imports to use Preact directly:

Before (React)

import * as React from "react";
import { useState, useEffect, useRef } from "react";

const MyContext = React.createContext(null);

After (Preact)

import { createContext } from "preact";
import { useState, useEffect, useRef } from "preact/hooks";

const MyContext = createContext(null);

Step 4: Update TypeScript Types

Replace React-specific types with Preact equivalents:

// Before
import type { ComponentProps, FC, ReactNode } from "react";

interface Props {
  children: ReactNode;
  className?: string;
}

const MyComponent: FC<Props> = ({ children, className }) => {
  // ...
};
// After
import type { ComponentProps, FunctionalComponent, ComponentChildren } from "preact";

interface Props {
  children: ComponentChildren;
  className?: string;
}

const MyComponent: FunctionalComponent<Props> = ({ children, className }) => {
  // ...
};

Step 5: Handle Context API

Context works almost identically in Preact:

// Before (React)
import * as React from "react";

const ThemeContext = React.createContext({ theme: 'light' });

export function ThemeProvider({ children }) {
  const [theme, setTheme] = React.useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return React.useContext(ThemeContext);
}
// After (Preact)
import { createContext } from "preact";
import { useState, useContext } from "preact/hooks";

const ThemeContext = createContext({ theme: 'light' });

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

Common Migration Patterns

Quick Reference Table

React Preact
import * as React from "react" import { h } from "preact"
React.createContext() createContext() from "preact"
React.useState() useState() from "preact/hooks"
React.useEffect() useEffect() from "preact/hooks"
React.useContext() useContext() from "preact/hooks"
React.useRef() useRef() from "preact/hooks"
React.useMemo() useMemo() from "preact/hooks"
React.useCallback() useCallback() from "preact/hooks"
React.ComponentProps<T> ComponentProps<T> from "preact"
React.FC<Props> FunctionalComponent<Props> from "preact"
React.ReactNode ComponentChildren from "preact"

Global Find & Replace

For faster migration, use these find-and-replace patterns:

# Replace Context
React.createContext → createContext

# Replace hooks (if using React.* syntax)
React.useState → useState
React.useEffect → useEffect
React.useContext → useContext
React.useRef → useRef
React.useMemo → useMemo
React.useCallback → useCallback

# Replace types
React.ComponentProps → ComponentProps
React.FC → FunctionalComponent
React.ReactNode → ComponentChildren

Handling Third-Party Libraries

Compatible Libraries

Most React libraries work seamlessly with Preact:

  • ✅ React Hook Form
  • ✅ Zustand
  • ✅ TanStack Query (React Query)
  • ✅ React Router (with preact/compat)
  • ✅ Headless UI (with preact/compat)

Potentially Problematic Libraries

Some libraries may need special attention:

Radix UI

Radix UI components may throw errors like:

TypeError: element.getBoundingClientRect is not a function

Solutions:

  1. Option 1: Switch to Headless UI (better Preact support)
  2. Option 2: Use the compatibility layer explicitly
  3. Option 3: Consider lightweight alternatives

Lucide Icons

For optimal bundle size, use the Preact version:

npm install lucide-preact
// Before
import { Code, Image, Link } from "lucide-react";

// After
import { Code, Image, Link } from "lucide-preact";

Testing Compatibility

Add this to your build config to test production compatibility:

resolve: {
  alias: process.env.NODE_ENV === 'production' 
    ? {
        'react': 'preact/compat',
        'react-dom': 'preact/compat',
      }
    : {}
}

This lets you test Preact in production while keeping React in development.

UI Component Libraries

Migrating from Radix UI to Headless UI

If you're using Radix UI and encounter compatibility issues, Headless UI is an excellent alternative with better Preact support:

// Before (Radix UI)
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";

function MyDropdown() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>Open</DropdownMenu.Trigger>
      <DropdownMenu.Content>
        <DropdownMenu.Item>Item 1</DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  );
}
// After (Headless UI)
import { Menu } from "@headlessui/react";

function MyDropdown() {
  return (
    <Menu>
      <Menu.Button>Open</Menu.Button>
      <Menu.Items>
        <Menu.Item>
          {({ active }) => (
            <button className={active ? 'bg-blue-500' : ''}>
              Item 1
            </button>
          )}
        </Menu.Item>
      </Menu.Items>
    </Menu>
  );
}

Building Custom Components

For maximum control and minimal bundle size, consider building lightweight custom components:

// Simple Avatar Component
interface AvatarProps {
  src?: string;
  alt?: string;
  fallback?: string;
}

export function Avatar({ src, alt = "", fallback }: AvatarProps) {
  const [status, setStatus] = useState<"loading" | "loaded" | "error">("loading");

  useEffect(() => {
    if (!src) {
      setStatus("error");
      return;
    }

    const img = new Image();
    img.src = src;
    img.onload = () => setStatus("loaded");
    img.onerror = () => setStatus("error");
  }, [src]);

  if (status === "loaded" && src) {
    return <img src={src} alt={alt} className="avatar-image" />;
  }

  return <div className="avatar-fallback">{fallback}</div>;
}

Performance Optimization

Code Splitting

Implement lazy loading for heavy components:

import { lazy, Suspense } from "preact/compat";

const EmojiPicker = lazy(() => import('./EmojiPicker'));
const ImageUploader = lazy(() => import('./ImageUploader'));

function Editor() {
  return (
    <div>
      <Suspense fallback={<div>Loading...</div>}>
        <EmojiPicker />
        <ImageUploader />
      </Suspense>
    </div>
  );
}

Tree Shaking

Configure your bundler for optimal tree shaking:

// vite.config.js
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['preact'],
          'ui-components': ['./src/components/ui'],
        },
      },
    },
  },
});

Resource Preloading

Preload critical resources:

<link rel="preload" as="script" href="/assets/editor.js">
<link rel="preload" as="style" href="/assets/styles.css">

Testing Your Migration

Unit Tests

Update test imports:

// Before
import { render, screen } from '@testing-library/react';

// After
import { render, screen } from '@testing-library/preact';

Integration Tests

Ensure all user flows work:

  1. Form submissions
  2. Dynamic content loading
  3. State management
  4. Context providers
  5. Event handlers

Performance Testing

Compare before and after metrics:

// Measure bundle size
npm run build
ls -lh dist/assets/*.js

// Measure load time (use Lighthouse)
npm run preview

Results: Real-World Impact

Here's what I achieved with my widget migration:

Bundle Size Reduction

┌─────────────────────┬──────────┬──────────┬────────────┐
│ Stack               │ Gzipped  │ Raw      │ Reduction  │
├─────────────────────┼──────────┼──────────┼────────────┤
│ React + Quill       │ 377 KB   │ 1.19 MB  │ baseline   │
│ Preact + Pell       │ 255 KB   │ 799 KB   │ 33% ↓      │
└─────────────────────┴──────────┴──────────┴────────────┘

Total Savings: 122KB gzipped / 395KB raw

Additional Optimizations

With Brotli compression enabled (.br files):
- Brotli: 224KB (15% better than gzip)
- Gzip: 255KB
- Uncompressed: 799KB

Performance Improvements

With a 33% bundle size reduction, you can expect:

  • Faster Load Time - Especially noticeable on 3G and slower connections
  • Improved Time to Interactive - Users can interact with your widget sooner
  • Reduced Memory Usage - Smaller bundle means less memory overhead
  • Better Core Web Vitals - Contributes to improved LCP and FID scores

User Experience

  • Smoother animations and interactions
  • Better mobile device performance
  • Faster form submissions
  • Reduced bandwidth consumption

Common Pitfalls and Solutions

Issue 1: Ref Forwarding Errors

Error:

TypeError: element.getBoundingClientRect is not a function

Solution:
Use forwardRef from Preact:

import { forwardRef } from "preact/compat";

const Button = forwardRef((props, ref) => {
  return <button ref={ref} {...props} />;
});

Issue 2: PropTypes Warnings

Error:

Warning: Failed prop type: Invalid prop `children` supplied to `Component`

Solution:
Preact doesn't include PropTypes by default. Either remove PropTypes or install them separately:

npm install prop-types

Issue 3: Synthetic Event Differences

Preact uses native events instead of synthetic events. Most code works fine, but if you're accessing event.persist() or checking event.nativeEvent, remove those patterns.

Migration Checklist

Pre-Migration

  • [ ] Document current bundle size
  • [ ] Set up performance baseline metrics
  • [ ] Review third-party dependencies
  • [ ] Create comprehensive test suite
  • [ ] Set up rollback plan
  • [ ] Create feature branch

During Migration

  • [ ] Install Preact dependencies
  • [ ] Update build configuration
  • [ ] Replace React imports with Preact
  • [ ] Update TypeScript types
  • [ ] Test each component individually
  • [ ] Fix compatibility issues
  • [ ] Run full test suite
  • [ ] Perform visual regression testing

Post-Migration

  • [ ] Measure new bundle size
  • [ ] Run performance tests
  • [ ] Monitor error tracking
  • [ ] Test on multiple devices
  • [ ] Deploy to staging
  • [ ] Gradual production rollout
  • [ ] Monitor production metrics
  • [ ] Document lessons learned

Rollback Strategy

If issues arise, here's how to roll back:

  1. Keep your React version in a separate branch
  2. Restore React dependencies: bash npm uninstall preact npm install react react-dom
  3. Revert build configuration changes
  4. Update imports back to React
  5. Run tests thoroughly
  6. Deploy previous version

When NOT to Migrate to Preact

Preact isn't always the right choice:

  • ❌ Using React Native (Preact is web-only)
  • ❌ Heavy reliance on React-specific libraries without alternatives
  • ❌ Team unfamiliar with potential compatibility issues
  • ❌ Application doesn't have bundle size constraints
  • ❌ Extensive use of Concurrent Mode features
  • ❌ Critical dependency on React 18+ exclusive features

Conclusion

Migrating from React + Quill to Preact + Pell delivered significant results for my widget:

  • 33% smaller bundle (377KB → 255KB gzipped)
  • 122KB saved over the wire
  • 395KB saved in raw size (1.19MB → 799KB)
  • Even smaller with Brotli (224KB with .br compression)

The migration was surprisingly smooth thanks to Preact's excellent compatibility layer. Most of my code required minimal changes, and the performance gains were immediate and noticeable.

Key Takeaways

  1. Both matter - Framework AND library choices compound (Quill was huge, React added to it)
  2. Use the compat layer - Makes migration nearly transparent
  3. Test thoroughly - Especially third-party component interactions
  4. Consider alternatives - Lightweight editor libraries can save more than framework swaps
  5. Use Brotli - 15% better compression than gzip (224KB vs 255KB in my case)

Is It Worth It?

For widgets, embedded apps, or any bundle-size-sensitive application: absolutely. The effort-to-reward ratio is excellent, especially if you're already using modern React patterns (hooks, functional components).

For large applications with complex dependencies, evaluate case-by-case. The bundle size savings might be worth the migration effort, but factor in your team's capacity and risk tolerance.

Resources


Have questions about migrating to Preact? Drop a comment below or reach out on X!