DEV Community

Cover image for From Next.js to React Edge with Cloudflare Workers: A Story of Liberation
Felipe Rohde
Felipe Rohde

Posted on

From Next.js to React Edge with Cloudflare Workers: A Story of Liberation

Quick Index

The Final Straw

It all started with a Vercel invoice. No, actually, it started way earlier—with small frustrations piling up. The need to pay for basic features like DDoS protection, more detailed logs, or even a decent firewall, build queues, etc. The feeling of being trapped in an increasingly expensive vendor lock-in.

"And worst of all: our precious SEO headers simply stopped rendering on the server in an application using the pages router. A true headache for any developer! 😭"

But what truly made me rethink everything was the direction Next.js was heading. The introduction of use client, use server—directives that, in theory, should simplify development but, in practice, added another layer of complexity to manage. It was like going back to the PHP days, marking files with directives to dictate where they should run.

And it doesn’t stop there. The App Router—an interesting idea but implemented in a way that created an almost entirely new framework within Next.js. Suddenly, there were two completely different ways to do the same thing—the 'old' and the 'new'—with subtly different behaviors and hidden pitfalls.

The Alternative with Cloudflare 😍

That’s when it hit me: why not leverage the incredible infrastructure of Cloudflare with Workers running on the edge, R2 for storage, KV for distributed data... Along with, of course, the amazing DDoS protection, global CDN, firewall, page rules and routing, and everything else Cloudflare has to offer.

And the best part: a fair pricing model where you pay for what you use, with no surprises.

This is how React Edge was born. A framework that doesn’t aim to reinvent the wheel but instead delivers a truly simple and modern development experience.

React Edge: The React Framework Born from Every Developer’s Pain (or Almost)

When I started developing React Edge, I had a clear goal: to create a framework that made sense. No more wrestling with confusing directives, no more paying exorbitant fees for basic features, and most importantly, no more dealing with artificial complexity caused by client/server separation. I wanted speed—a framework that delivers performance without sacrificing simplicity. Leveraging my knowledge of React’s API and years of experience as a JavaScript and Golang developer, I knew exactly how to handle streams and multiplexing to optimize rendering and data management.

Cloudflare Workers, with its powerful infrastructure and global presence, provided the perfect environment to explore these possibilities. I wanted something truly hybrid, and this combination of tools and experience gave life to React Edge: a framework that solves real-world problems with modern and efficient solutions.

React Edge introduces a revolutionary approach to React development. Imagine writing a class on the server and calling it directly from the client, with full type safety and zero configuration. Imagine a distributed caching system that "just works," allowing invalidation by tags or prefixes. Imagine sharing state seamlessly and securely between server and client. Add simplified authentication, an efficient internationalization system, CLI, and more.

Its RPC communication feels almost magical—you write methods in a class and call them from the client as if they were local. The intelligent multiplexing system ensures that even if multiple components make the same call, only one request is sent to the server. Ephemeral caching avoids unnecessary repeated requests, and it all works seamlessly on both the server and the client.

One of the most powerful features is the app.useFetch hook, which unifies the data-fetching experience. On the server, it preloads data during SSR; on the client, it automatically hydrates with those data and supports on-demand updates. With support for automatic polling and dependency-based reactivity, creating dynamic interfaces has never been easier.

But that’s not all. The framework offers a powerful routing system (inspired by the fantastic Hono), integrated asset management with Cloudflare R2, and an elegant way to handle errors via the HttpError class. Middlewares can easily send data to the client through a shared store, and everything is automatically obfuscated for security.

The most impressive part? Almost all of the framework’s code is hybrid. There isn’t a “client” version and a “server” version—the same code works in both environments, adapting automatically to the context. The client receives only what it needs, making the final bundle extremely optimized.

And the icing on the cake: all of this runs on the Cloudflare Workers edge infrastructure, delivering exceptional performance at a fair cost. No surprise invoices, no basic features locked behind expensive enterprise plans—just a solid framework that lets you focus on what truly matters: building amazing applications. Additionally, React Edge leverages Cloudflare’s ecosystem, including Queues, Durable Objects, KV Storage, and more, providing a robust and scalable foundation for your applications.

Vite was used as the base for the development environment, testing, and build process. With its impressive speed and modern architecture, Vite enables an agile and efficient workflow. It not only accelerates development but also optimizes the build process, ensuring fast and accurate compilation. Without a doubt, Vite was the perfect choice for React Edge.

Rethinking React Development for the Edge Computing Era

Have you ever wondered what it would be like to develop React applications without worrying about the client/server barrier? Without memorizing dozens of directives like use client or use server? Better yet: what if you could call server functions as if they were local, with full typing and zero configuration?

With React Edge, you no longer need to:

  • Create separate API routes
  • Manually manage loading/error state
  • Implement debounce yourself
  • Worry about serialization/deserialization
  • Deal with CORS
  • Manage typing between client/server
  • Handle authentication rules manually
  • Struggle with internationalization setup

And the best part: all this works seamlessly on both the server and client without marking anything as use client or use server. The framework automatically knows what to do based on the context. Shall we dive in?

The Magic of Typed RPC

Imagine being able to do this:

// On the server
class UserAPI extends Rpc {
  async searchUsers(query: string, filters: UserFilters) {
    // Validation with Zod
    const validated = searchSchema.parse({ query, filters });
    return this.db.users.search(validated);
  }
}

// On the client
const UserSearch = () => {
  const { rpc } = app.useContext<App.Context>();

  // TypeScript knows exactly what searchUsers accepts and returns!
  const { data, loading, error, fetch: retry } = app.useFetch(
    async (ctx) => ctx.rpc.searchUsers('John', { age: '>18' })
  );
};
Enter fullscreen mode Exit fullscreen mode

Compare this with Next.js/Vercel:

// pages/api/search.ts
export default async handler = (req, res) => {
  // Configure CORS
  // Validate request
  // Handle errors
  // Serialize response
  // ...100 lines later...
}

// app/search/page.tsx
'use client';
import { useEffect, useState } from 'react';

export default const SearchPage = () => {
  const [search, setSearch] = useState('');
  const [filters, setFilters] = useState({});
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let timeout;
    const doSearch = async () => {
      setLoading(true);
      try {
        const res = await fetch('/api/search?' + new URLSearchParams({
          q: search,
          ...filters
        }));
        if (!res.ok) throw new Error('Search failed');
        setData(await res.json());
      } catch (err) {
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    timeout = setTimeout(doSearch, 300);
    return () => clearTimeout(timeout);
  }, [search, filters]);

  // ... rest of the component
}
Enter fullscreen mode Exit fullscreen mode

The Power of useFetch: Where the Magic Happens

Rethinking Data Fetching

Forget everything you know about data fetching in React. The app.useFetch hook from React Edge introduces a completely new and powerful approach. Imagine a hook that:

  • Preloads data on the server during SSR.
  • Automatically hydrates data on the client without flicker.
  • Maintains full typing between client and server.
  • Supports reactivity with intelligent debounce.
  • Automatically multiplexes identical calls.
  • Enables programmatic updates and polling.

Let’s see it in action:

// First, define your API on the server
class PropertiesAPI extends Rpc {
  async searchProperties(filters: PropertyFilters) {
    const results = await this.db.properties.search(filters);
    // Automatic caching for 5 minutes
    return this.createResponse(results, {
      cache: { ttl: 300, tags: ['properties'] }
    });
  }

  async getPropertyDetails(ids: string[]) {
    return Promise.all(
      ids.map(id => this.db.properties.findById(id))
    );
  }
}

// Now, on the client, the magic happens
const PropertySearch = () => {
  const [filters, setFilters] = useState<PropertyFilters>({
    price: { min: 100000, max: 500000 },
    bedrooms: 2
  });

  // Reactive search with intelligent debounce
  const {
    data: searchResults,
    loading: searchLoading,
    error: searchError
  } = app.useFetch(
    async (ctx) => ctx.rpc.searchProperties(filters),
    {
      // Re-fetch when filters change
      deps: [filters],
      // Wait 300ms of "silence" before fetching
      depsDebounce: {
        filters: 300
      }
    }
  );

  // Fetch property details for the found results
  const {
    data: propertyDetails,
    loading: detailsLoading,
    fetch: refreshDetails
  } = app.useFetch(
    async (ctx) => {
      if (!searchResults?.length) return null;

      // This looks like multiple calls, but...
      return ctx.rpc.batch([
        // Everything is multiplexed into a single request!
        ...searchResults.map(result =>
          ctx.rpc.getPropertyDetails(result.id)
        )
      ]);
    },
    {
      // Refresh when searchResults change
      deps: [searchResults]
    }
  );

  // A beautiful and responsive interface
  return (
    <div>
      <FiltersPanel
        value={filters}
        onChange={setFilters}
        disabled={searchLoading}
      />

      {searchError && (
        <Alert status='error'>
          Search error: {searchError.message}
        </Alert>
      )}

      <PropertyGrid
        items={propertyDetails || []}
        loading={detailsLoading}
        onRefresh={() => refreshDetails()}
      />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The Magic of Multiplexing

The example above hides a powerful feature: intelligent multiplexing. When you use ctx.rpc.batch, React Edge not only groups calls—it also deduplicates identical calls automatically:

const PropertyListingPage = () => {
  const { data } = app.useFetch(async (ctx) => {
    // Even if you make 100 identical calls...
    return ctx.rpc.batch([
      ctx.rpc.getProperty('123'),
      ctx.rpc.getProperty('123'), // same call
      ctx.rpc.getProperty('456'),
      ctx.rpc.getProperty('456'), // same call
    ]);
  });

  // Behind the scenes:
  // 1. The batch groups all calls into ONE single HTTP request.
  // 2. Identical calls are deduplicated automatically.
  // 3. Results are distributed correctly to each position in the array.
  // 4. Typing is maintained for each individual result!

  // Actual RPC calls:
  // 1. getProperty('123')
  // 2. getProperty('456')
  // Results are distributed correctly to all callers!
};
Enter fullscreen mode Exit fullscreen mode

SSR + Perfect Hydration

One of the most impressive parts is how useFetch handles SSR:

const ProductPage = ({ productId }: Props) => {
  const { data, loaded, loading, error } = app.useFetch(
    async (ctx) => ctx.rpc.getProduct(productId),
    {
      // Fine-grained control over when to fetch
      shouldFetch: ({ worker, loaded }) => {
        // On the worker (SSR): always fetch
        if (worker) return true;
        // On the client: fetch only if no data is loaded
        return !loaded;
      }
    }
  );

  // On the server:
  // 1. `useFetch` makes the RPC call.
  // 2. Data is serialized and sent to the client.
  // 3. Component renders with the data.

  // On the client:
  // 1. Component hydrates with server data.
  // 2. No new call is made (shouldFetch returns false).
  // 3. If necessary, you can re-fetch with `data.fetch()`.

  return (
    <Suspense fallback={<ProductSkeleton />}>
      <ProductView
        product={data}
        loading={loading}
        error={error}
      />
    </Suspense>
  );
};
Enter fullscreen mode Exit fullscreen mode

Beyond useFetch: The Complete Arsenal

RPC: The Art of Client-Server Communication

Security and Encapsulation

The RPC system in React Edge is designed with security and encapsulation in mind. Not everything in an RPC class is automatically exposed to the client:

class PaymentsAPI extends Rpc {
  // Properties are never exposed
  private stripe = new Stripe(process.env.STRIPE_KEY);

  // Methods starting with $ are private
  private async $validateCard(card: CardInfo) {
    return await this.stripe.cards.validate(card);
  }

  // Methods starting with _ are also private
  private async _processPayment(amount: number) {
    return await this.stripe.charges.create({ amount });
  }

  // This method is public and accessible via RPC
  async createPayment(orderData: OrderData) {
    // Internal validation using a private method
    const validCard = await this.$validateCard(orderData.card);
    if (!validCard) {
      throw new HttpError(400, 'Invalid card');
    }

    // Processing using another private method
    const payment = await this._processPayment(orderData.amount);
    return payment;
  }
}

// On the client:
const PaymentForm = () => {
  const { rpc } = app.useContext<App.Context>();

  // ✅ This works
  const handleSubmit = () => rpc.createPayment(data);

  // ❌ These do not work - private methods are not exposed
  const invalid1 = () => rpc.$validateCard(data);
  const invalid2 = () => rpc._processPayment(100);

  // ❌ This also does not work - properties are not exposed
  const invalid3 = () => rpc.stripe;
};
Enter fullscreen mode Exit fullscreen mode

RPC API Hierarchies

One of the most powerful features of RPC is the ability to organize APIs into hierarchies:

// Nested APIs for better organization
class UsersAPI extends Rpc {
  // Subclass to manage preferences
  preferences = new UserPreferencesAPI();
  // Subclass to manage notifications
  notifications = new UserNotificationsAPI();

  async getProfile(id: string) {
    return this.db.users.findById(id);
  }
}

class UserPreferencesAPI extends Rpc {
  async getTheme(userId: string) {
    return this.db.preferences.getTheme(userId);
  }

  async setTheme(userId: string, theme: Theme) {
    return this.db.preferences.setTheme(userId, theme);
  }
}

class UserNotificationsAPI extends Rpc {
  // Private methods remain private
  private async $sendPush(userId: string, message: string) {
    await this.pushService.send(userId, message);
  }

  async getSettings(userId: string) {
    return this.db.notifications.getSettings(userId);
  }

  async notify(userId: string, notification: Notification) {
    const settings = await this.getSettings(userId);
    if (settings.pushEnabled) {
      await this.$sendPush(userId, notification.message);
    }
  }
}

// On the client:
const UserProfile = () => {
  const { rpc } = app.useContext<App.Context>();

  const { data: profile } = app.useFetch(
    async (ctx) => {
      // Nested calls are fully typed
      const [user, theme, notificationSettings] = await ctx.rpc.batch([
        // Method from the main class
        ctx.rpc.getProfile('123'),
        // Method from the preferences subclass
        ctx.rpc.preferences.getTheme('123'),
        // Method from the notifications subclass
        ctx.rpc.notifications.getSettings('123')
      ]);

      return { user, theme, notificationSettings };
    }
  );

  // ❌ Private methods remain inaccessible
  const invalid = () => rpc.notifications.$sendPush('123', 'hello');
};
Enter fullscreen mode Exit fullscreen mode

Benefits of Hierarchies

Organizing APIs into hierarchies provides several benefits:

  • Logical Organization: Group related functionalities intuitively.
  • Natural Namespacing: Avoid name conflicts with clear paths (e.g., users.preferences.getTheme).
  • Encapsulation: Keep helper methods private at each level.
  • Maintainability: Each subclass can be maintained and tested independently.
  • Full Typing: TypeScript understands the entire hierarchy.

The RPC system in React Edge makes client-server communication so natural that you almost forget you’re making remote calls. With the ability to organize APIs into hierarchies, you can build complex structures while keeping your code clean and secure.

A System of i18n That Makes Sense

React Edge introduces an elegant and flexible internationalization system that supports variable interpolation and complex formatting without relying on heavyweight libraries.

// translations/fr.ts
export default {
  'Good Morning, {name}!': 'Bonjour, {name}!',
};
Enter fullscreen mode Exit fullscreen mode

Usage in the code:

const WelcomeMessage = () => {
  const userName = 'John';

  return (
    <div>
      {/* Output: Good Morning, John! */}
      <h1>{__('Good Morning, {name}!', { name: userName })}</h1>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Zero Configuration

React Edge automatically detects and loads your translations. It even allows saving user preferences in cookies effortlessly. But, of course, you’d expect this, right?

// worker.ts
const handler = {
  fetch: async (request: Request, env: types.Worker.Env, context: ExecutionContext) => {
    const url = new URL(request.url);

    const lang = (() => {
      const lang =
        url.searchParams.get('lang') || worker.cookies.get(request.headers, 'lang') || request.headers.get('accept-language') || '';

      if (!lang || !i18n[lang]) {
        return 'en-us';
      }

      return lang;
    })();

    const workerApp = new AppWorkerEntry({
      i18n: {
        en: await import('./translations/en'),
        pt: await import('./translations/pt'),
        es: await import('./translations/es')
      }
    });

    const res = await workerApp.fetch();

    if (url.searchParams.has('lang')) {
      return new Response(res.body, {
        headers: worker.cookies.set(res.headers, 'lang', lang)
      });
    }

    return res;
  }
};
Enter fullscreen mode Exit fullscreen mode

JWT Authentication That "Just Works"

Authentication has always been a pain point in web applications. Managing JWT tokens, secure cookies, and revalidation often requires a lot of boilerplate code. React Edge completely changes this.

Here’s how simple it is to implement a full authentication system:

class SessionAPI extends Rpc {
  private auth = new AuthJwt({
    // Cookie is automatically managed
    cookie: 'token',
    // Payload is automatically encrypted
    encrypt: true,
    // Automatic expiration
    expires: { days: 1 },
    secret: process.env.JWT_SECRET,
  });

  async signin(credentials: { email: string; password: string }) {
    // Validation with Zod
    const validated = loginSchema.parse(credentials);

    // Signs the token and sets the headers automatically
    const { headers } = await this.auth.sign(validated);

    // Returns a response with configured cookies
    return this.createResponse(
      { email: validated.email },
      { headers }
    );
  }

  async getSession(revalidate = false) {
    // Automatic token validation and revalidation
    const { headers, payload } = await this.auth.authenticate(
      this.request.headers,
      revalidate
    );

    return this.createResponse(payload, { headers });
  }

  async signout() {
    // Automatically clears cookies
    const { headers } = await this.auth.destroy();
    return this.createResponse(null, { headers });
  }
}
Enter fullscreen mode Exit fullscreen mode

Client Usage: Zero Configuration

const LoginForm = () => {
  const { rpc } = app.useContext<App.Context>();

  const login = async (values) => {
    const session = await rpc.signin(values);
    // Done! Cookies are automatically set
  };

  return <Form onSubmit={login}>...</Form>;
};

const NavBar = () => {
  const { rpc } = app.useContext<App.Context>();

  const logout = async () => {
    await rpc.signout();
    // Cookies are automatically cleared
  };

  return <button onClick={logout}>Log Out</button>;
};
Enter fullscreen mode Exit fullscreen mode

Why Is This Revolutionary?

  1. Zero Boilerplate

    • No manual cookie management
    • No need for interceptors
    • No manual refresh tokens
  2. Security by Default

    • Tokens are automatically encrypted
    • Cookies are secure and httpOnly
    • Automatic revalidation
  3. Full Typing

    • JWT payload is typed
    • Integrated Zod validation
    • Typed authentication errors
  4. Seamless Integration

// Middleware to protect routes
const authMiddleware: App.Middleware = async (ctx) => {
  const session = await ctx.rpc.session.getSession();

  if (!session) {
    throw new HttpError(401, 'Unauthorized');
  }

  // Makes the session available to components
  ctx.store.set('session', session, 'public');
};

// Usage in routes
const router: App.Router = {
  routes: [
    routerBuilder.routeGroup({
      path: '/dashboard',
      middlewares: [authMiddleware],
      routes: [/*...*/],
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

The Shared Store

One of the most powerful features of React Edge is its ability to securely share state between the worker and the client. Here's how it works:

Example of Middleware and Store Usage

// middleware/auth.ts
const authMiddleware: App.Middleware = async (ctx) => {
  const token = ctx.request.headers.get('authorization');

  if (!token) {
    throw new HttpError(401, 'Unauthorized');
  }

  const user = await validateToken(token);

  // Public data - automatically shared with the client
  ctx.store.set(
    'user',
    {
      id: user.id,
      name: user.name,
      role: user.role,
    },
    'public'
  );

  // Private data - remains accessible only within the worker
  ctx.store.set('userSecret', user.secret);
};

// components/Header.tsx
const Header = () => {
  // Acesso transparente aos dados do store
  const { store } = app.useContext();
  const user = store.get('user');

  return (
    <header>
      <h1>Bem vindo, {user.name}!</h1>
      {user.role === 'admin' && (
        <AdminPanel />
      )}
    </header>
  );
};
Enter fullscreen mode Exit fullscreen mode

How It Works

  • Public Data: Data marked as public is securely shared with the client, making it easily accessible for components.
  • Private Data: Sensitive data remains within the worker’s environment and is never exposed to the client.
  • Integration with Middleware: Middleware can populate the store with both public and private data, ensuring a seamless flow of information between server-side logic and client-side rendering.

Benefits

  1. Security: Separate public and private data scopes ensure sensitive information stays protected.
  2. Convenience: Transparent access to store data simplifies state management across worker and client.
  3. Flexibility: The store is easily integrated with middleware, allowing dynamic state updates based on request handling.

Elegant Routing

The routing system of React Edge is inspired by Hono, but with enhanced features for SSR:

const router: App.Router = {
  routes: [
    routerBuilder.routeGroup({
      path: '/dashboard',
      // Middlewares applied to all routes in the group
      middlewares: [authMiddleware, dashboardMiddleware],
      routes: [
        routerBuilder.route({
          path: '/',
          handler: {
            page: {
              value: DashboardPage,
              // Specific headers for this route
              headers: new Headers({
                'Cache-Control': 'private, max-age=0'
              })
            }
          }
        }),
        routerBuilder.route({
          path: '/api/stats',
          handler: {
            // Routes can return direct responses
            response: async (ctx) => {
              const stats = await ctx.rpc.stats.getDashboardStats();
              return {
                value: Response.json(stats),
                // Cache for 5 minutes
                cache: { ttl: 300 }
              };
            }
          }
        })
      ]
    })
  ]
};
Enter fullscreen mode Exit fullscreen mode

Key Features

  • Grouped Routes: Logical grouping of related routes under a shared path and middleware.
  • Flexible Handlers: Define handlers that return pages or direct API responses.
  • Per-Route Headers: Customize HTTP headers for individual routes.
  • Built-in Caching: Simplify caching strategies with ttl and tags.

Benefits

  1. Consistency: By grouping related routes, you ensure consistent middleware application and code organization.
  2. Scalability: The system supports nested and modular routing for large-scale applications.
  3. Performance: Native support for caching ensures optimal response times without manual configurations.

Distributed Cache with Edge Cache

React Edge includes a powerful caching system that works seamlessly for both JSON data and entire pages. This caching system supports intelligent tagging and prefix-based invalidation, making it suitable for a wide range of scenarios.

Example: Caching API Responses with Tags

class ProductsAPI extends Rpc {
  async getProducts(category: string) {
    const products = await this.db.products.findByCategory(category);

    return this.createResponse(products, {
      cache: {
        ttl: 3600, // 1 hour
        tags: [`category:${category}`, 'products'],
      },
    });
  }

  async updateProduct(id: string, data: ProductData) {
    await this.db.products.update(id, data);

    // Invalidate specific product cache and its category
    await this.cache.deleteBy({
      tags: [`product:${id}`, `category:${data.category}`],
    });
  }

  async searchProducts(query: string) {
    const results = await this.db.products.search(query);

    // Cache with a prefix for easy invalidation
    return this.createResponse(results, {
      cache: {
        ttl: 300, // 5 minutes
        tags: [`search:${query}`],
      },
    });
  }
}

// Global cache invalidation
await cache.deleteBy({
  // Invalidate all search results
  keyPrefix: 'search:',
  // And all products in a specific category
  tags: ['category:electronics'],
});
Enter fullscreen mode Exit fullscreen mode

Key Features

  • Tag-Based Invalidation: Cache entries can be grouped using tags, allowing for easy and selective invalidation when data changes.
  • Prefix Matching: Invalidate multiple cache entries using a common prefix, ideal for scenarios like search queries or hierarchical data.
  • Time-to-Live (TTL): Set expiration times for cache entries to ensure data freshness while maintaining high performance.

Benefits

  1. Improved Performance: Reduce load on APIs by serving cached responses for frequently accessed data.
  2. Scalability: Efficiently handle large-scale datasets and high traffic with a distributed caching system.
  3. Flexibility: Fine-grained control over caching, enabling developers to optimize performance without sacrificing data accuracy.

Link: The Component That Thinks Ahead

The Link component is an intelligent and performance-oriented solution for preloading client-side resources, ensuring a smoother and faster navigation experience for users. Its prefetching functionality is triggered when the user hovers over the link, taking advantage of idle moments to request destination data in advance.

How It Works

  1. Conditional Prefetching: The prefetch attribute (enabled by default) controls whether the preloading is executed.
  2. Intelligent Cache: A Set is used to store already prefetched links, avoiding redundant fetch calls.
  3. Mouse Enter Event: When the user hovers over the link, the handleMouseEnter function checks if preloading is necessary and, if so, starts a fetch request for the destination.
  4. Error Resilience: Any failure during the request is suppressed, ensuring the component's behavior is unaffected by temporary network issues.

Example Usage

<app.Link href="/about" prefetch>
  About Us
</app.Link>
Enter fullscreen mode Exit fullscreen mode

When the user hovers over the “About Us” link, the component will start preloading the data for the /about page, ensuring an almost instant transition. Genius idea, isn’t it? Inspired by the react.dev documentation.

app.useContext: The Portal to the Edge

The app.useContext hook is a cornerstone of React Edge, granting seamless access to the worker's entire context. It provides a powerful interface for managing routing, state, RPC calls, and more.

Example: Using app.useContext in a Dashboard

const DashboardPage = () => {
  const {
    // Current route parameters
    pathParams,
    // Parsed query parameters
    searchParams,
    // Matched route
    path,
    // Original route (with parameters)
    rawPath,
    // RPC proxy
    rpc,
    // Shared store
    store,
    // Full URL
    url,
  } = app.useContext<App.Context>();

  // Fully typed RPC call
  const { data } = app.useFetch(
    async (ctx) => ctx.rpc.getDashboardStats()
  );

  // Accessing shared store data
  const user = store.get('user');

  return (
    <div>
      <h1>Welcome to your dashboard, {user.name}</h1>
      <p>Currently viewing: {path}</p>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Key Features of app.useContext

  • Route Management: Gain access to the matched route, its parameters, and query strings effortlessly.
  • RPC Integration: Make typed and secure RPC calls directly from the client with no additional configuration.
  • Shared Store Access: Retrieve or set values in the shared worker-client state with complete control over visibility (public/private).
  • Universal URL Access: Easily access the full URL of the current request for dynamic rendering and interactions.

Why It’s Powerful

The app.useContext hook bridges the gap between the worker and the client. It allows you to build features that rely on shared state, secure data fetching, and contextual rendering without boilerplate. This simplifies complex applications, making them easier to maintain and faster to develop.

app.useUrlState: State Synced with the URL

The app.useUrlState hook keeps your application state in sync with the URL query parameters, offering fine-grained control over what is included in the URL, how the state is serialized, and when it updates.

const ProductsPage = () => {
  // State synced with URL automatically
  const [filters, setFilters] = app.useUrlState({
      category: 'all',
      minPrice: 0,
      maxPrice: 1000,
      other: {
          foo: 'bar',
          baz: 'qux'
      }
    }, {
        debounce: 500,              // Debounce URL updates by 500ms
        kebabCase: true,            // Converts keys to kebab-case for cleaner URLs
        omitKeys: ['filter.locations'], // Exclude specific keys from the URL
        omitValues: [],             // Exclude specific values from the URL
        pickKeys: [], // Include only specific keys in the URL
        prefix: '',                 // Add an optional prefix to query keys
        url: ctx.url                // Use the current URL from the context (works server side)
    });

  const { data } = app.useFetch(
    async (ctx) => ctx.rpc.products.search(filters),
    {
      // Refetch quando filters mudar
      deps: [filters]
    }
  );

  return (
    <div>
      <FiltersPanel
        value={filters}
        onChange={(newFilters) => {
          // URL Ă© atualizada automaticamente
          setFilters(newFilters);
        }}
      />
      <ProductGrid data={data} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Parameters

  1. Initial State

    • An object defining the default structure and values for your state.
  2. Options:

    • debounce: Controls how quickly the URL is updated after state changes. Useful for preventing excessive updates.
    • kebabCase: Converts state keys to kebab-case when serializing to the URL (e.g., filter.locations → filter-locations).
    • omitKeys: Specifies keys to exclude from the URL. For example, sensitive data or large objects can be omitted.
    • omitValues: Values that, when present, will exclude the associated key from the URL.
    • pickKeys: Limits the serialized state to only include specified keys.
    • prefix: Adds a prefix to all query parameters for namespacing.
    • url: The base URL to sync with, typically derived from the app context.

Benefits

  • Identical useState API: Easy integration with existing components.
  • SEO-Friendly: Ensures state-dependent views are reflected in sharable and bookmarkable URLs.
  • Debounced Updates: Prevents excessive query updates for rapidly changing inputs, like sliders or text boxes.
  • Clean URLs: Options like kebabCase and omitKeys keep query strings readable and relevant.
  • State Hydration: Automatically initializes state from the URL on component mount, making deep linking seamless.
  • Works Everywhere: Supports server-side rendering and client-side navigation, ensuring consistent state across the application.

Practical Applications

  • Filters for Property Listings: Sync user-applied filters like listingTypes and map bounds to the URL for sharable searches.
  • Dynamic Views: Ensure map zoom, center points, or other view settings persist across page refreshes or links.
  • User Preferences: Save user-selected settings in the URL for easy sharing or bookmarking.

app.useStorageState: Persistent State

The app.useStorageState hook allows you to persist state in the browser using localStorage or sessionStorage, with full TypeScript support.

type RecentSearch = {
  term: string;
  date: string;
  category: string;
}

type SearchState = {
  recentSearches: RecentSearch[];
  favorites: string[];
  lastSearch?: string;
}

const RecentSearches = () => {
  // Initialize state with typing and default values
  const [searchState, setSearchState] = app.useStorageState<SearchState>(
    // Unique key for storage
    'user-searches',
    // Initial state
    {
      recentSearches: [],
      favorites: [],
      lastSearch: undefined
    },
    // Configuration options
    {
      // Delays saving to storage by 500ms to optimize performance
      debounce: 500,

      // Sets the storage type:
      // 'local' - persists even after closing the browser
      // 'session' - persists only during the session
      storage: 'local',

      // Exclude these keys when saving to storage
      omitKeys: ['lastSearch'],

      // Save only these specific keys
      pickKeys: ['recentSearches', 'favorites']
    }
  );

  return (
    <div className="recent-searches">
      <h3>Recent Searches</h3>

      {/* List recent searches */}
      <ul>
        {searchState.recentSearches.map((search, index) => (
          <li key={index}>
            <span>{search.term}</span>
            <small>{search.date}</small>

            {/* Button to favorite search */}
            <button
              onClick={() => {
                setSearchState({
                  ...searchState,
                  favorites: [...searchState.favorites, search.term]
                })
              }}
            >
              ⭐
            </button>
          </li>
        ))}
      </ul>

      {/* Button to clear history */}
      <button
        onClick={() => {
          setSearchState({
            ...searchState,
            recentSearches: []
          })
        }}
      >
        Clear History
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Persistence Options

  • debounce: Controls the frequency of saves to storage.
  • storage: Choose between localStorage and sessionStorage.
  • omitKeys/pickKeys: Fine-grained control over which data is persisted.

Performance

  • Optimized updates with debounce.
  • Automatic serialization/deserialization.
  • In-memory caching.

Common Use Cases

  • Search history
  • Favorites list
  • User preferences
  • Filter state
  • Temporary shopping cart
  • Draft forms

app.useDebounce: Frequency Control

Debounce reactive values effortlessly:

const SearchInput = () => {
  const [input, setInput] = useState('');

  // Debounced value updates only after 300ms of 'silence'
  const debouncedValue = app.useDebounce(input, 300);

  const { data } = app.useFetch(
    async (ctx) => ctx.rpc.search(debouncedValue),
    {
      // Fetch occurs only when the debounced value changes
      deps: [debouncedValue]
    }
  );

  return (
    <div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder='Search...'
      />
      <SearchResults data={data} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

app.useDistinct: State Without Duplicates

Keep arrays with unique values while maintaining type safety.

The app.useDistinct hook specializes in detecting when a value has truly changed, with support for deep comparison and debounce:

const SearchResults = () => {
  const [search, setSearch] = useState('');

  // Detect distinct changes in the search value
  const {
    value: currentSearch,   // Current value
    prevValue: lastSearch,  // Previous value
    distinct: hasChanged    // Indicates if a distinct change occurred
  } = app.useDistinct(search, {
    // Debounce by 300ms
    debounce: 300,
    // Enable deep comparison
    deep: true,
    // Custom comparison function
    compare: (a, b) => a?.toLowerCase() === b?.toLowerCase()
  });

  // Fetch only when the search value has distinctly changed
  const { data } = app.useFetch(
    async (ctx) => ctx.rpc.search(currentSearch),
    {
      deps: [currentSearch],
      // Execute fetch only if there's a distinct change
      shouldFetch: () => hasChanged
    }
  );

  return (
    <div>
      <input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search..."
      />

      {hasChanged && (
        <small>
          Search updated from '{lastSearch}' to '{currentSearch}'
        </small>
      )}

      <SearchResults data={data} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Key Features

  1. Distinct Value Detection:
    • Tracks the current and previous values.
    • Automatically detects if a change is meaningful based on your criteria.
  2. Deep Comparison:
    • Enables value equality checks at a deep level for complex objects.
  3. Custom Comparison:
    • Supports custom functions to define what constitutes a “distinct” change.
  4. Debounced:
    • Reduces unnecessary updates when changes occur too frequently.

Benefits

  • Identical useState API: Easy integration with existing components.
  • Optimized Performance: Avoids unnecessary re-fetching or re-computation when the value hasn’t meaningfully changed.
  • Enhanced UX: Prevents overreactive UI updates, leading to smoother interactions.
  • Simplified Logic: Eliminates manual checks for equality or duplication in state management.

The hooks in React Edge are designed to work in harmony, providing a fluid and strongly typed development experience. Their combination allows for creating complex and reactive interfaces with much less code.

The React Edge CLI: Power at Your Fingertips

The CLI for React Edge was designed to simplify developers' lives by gathering essential tools into a single, intuitive interface. Whether you're a beginner or an experienced developer, the CLI ensures you can configure, develop, test, and deploy projects efficiently and effortlessly.

Key Features

Modular and Flexible Commands:

  • build: Builds both the app and the worker, with options to specify environments and modes (development or production).
  • dev: Starts local or remote development servers, allowing separate work on the app or worker.
  • deploy: Enables fast and efficient deployments leveraging the combined power of Cloudflare Workers and Cloudflare R2, ensuring performance and scalability in edge infrastructure.
  • logs: Monitors worker logs directly in the terminal.
  • lint: Automates Prettier and ESLint execution, with support for auto-fixes.
  • test: Runs tests with optional coverage using Vitest.
  • type-check: Validates TypeScript typing across the project.

Real-World Use Cases

I’m proud to share that the first production application using React Edge is already live! It's a Brazilian real estate company, Lopes Imóveis, which is already reaping the benefits of the framework's performance and flexibility.

On their website, properties are loaded into cache to optimize search and provide a smoother user experience. Since it's a highly dynamic site, route caching uses a TTL of just 10 seconds, combined with the stale-while-revalidate strategy. This ensures the site delivers updated data with exceptional performance, even during background revalidations.

Additionally, recommendations for similar properties are calculated efficiently and asynchronously in the background, then saved directly to Cloudflare's cache using the integrated RPC caching system. This approach reduces response times for subsequent requests and makes querying recommendations nearly instantaneous. All images are stored on Cloudflare R2, offering scalable and distributed storage without relying on external providers.

app in production using react edge
app in production using react edge

Soon, we’ll also launch a massive automated marketing project for Easy Auth, showcasing the potential of this technology even further.

Conclusion

And so, dear readers, we’ve reached the end of this journey through the world of React Edge! I know there’s still a sea of incredible features to explore, like simpler authentication options such as Basic and Bearer, and other tricks that make a developer’s day much happier. But hold on! The idea is to bring more detailed articles in the future to dive deep into each of these features.

Spoiler alert: soon, React Edge will be open source and properly documented! Balancing development, work, writing, and a little bit of social life isn’t easy, but the excitement of seeing this marvel in action, especially with the absurd speed provided by Cloudflare's infrastructure, is the fuel that keeps me going. So, stay tuned, because the best is yet to come! 🚀

In the meantime, if you want to start exploring and testing it right now, the package is already available on NPM: React Edge on NPM..

My email is feliperohdee@gmail.com, and I’m always open to feedback—this is just the beginning of this journey. Suggestions and constructive criticism are welcome. If you enjoyed what you read, share it with your friends and colleagues, and stay tuned for more updates. Thank you for reading this far, and see you next time! 🚀🚀🚀

Top comments (0)