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:
- Option 1: Switch to Headless UI (better Preact support)
- Option 2: Use the compatibility layer explicitly
- 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:
- Form submissions
- Dynamic content loading
- State management
- Context providers
- 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:
- Keep your React version in a separate branch
- Restore React dependencies:
bash npm uninstall preact npm install react react-dom - Revert build configuration changes
- Update imports back to React
- Run tests thoroughly
- 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
.brcompression)
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
- Both matter - Framework AND library choices compound (Quill was huge, React added to it)
- Use the compat layer - Makes migration nearly transparent
- Test thoroughly - Especially third-party component interactions
- Consider alternatives - Lightweight editor libraries can save more than framework swaps
- 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!