DEV Community

Cover image for Component hydration patterns that actually work with Jaspr
Salih Guler
Salih Guler

Posted on

Component hydration patterns that actually work with Jaspr

Every framework with server-side rendering faces the same problem. You render HTML on the server, send it to the browser, then your JavaScript needs to take over without breaking what's already there. This is hydration, and most frameworks make you think about it constantly.

React introduced SSR years ago. In the older "Pages Router" model, you had to serialize state manually and often fought hydration mismatches. Modern Next.js (App Router) improved this with React Server Components, which stream data to the client automatically. However, you are still managing the "boundary" between server and client explicitly. You mark components with "use server" or "use client" and carefully manage which code runs where. Vue and Svelte have similar patterns. Every component that needs server data requires explicit data fetching and passing.

The core issue is that frameworks treat server rendering and client rendering as separate concerns. You render on the server, somehow get the data to the client, then render again. If these two renders produce different output, hydration breaks. If you forget to pass some state, components remount with empty data and flicker.

Jaspr takes a different approach. Components are universal by default. State syncs automatically. The same component code runs on server and client, and the framework handles transferring state between them without manual serialization.

This isn't specific to Jaspr. The pattern applies to any SSR framework. Some implement it better than others. Understanding how automatic hydration works makes you better at building SSR apps in any framework.

Why manual hydration is error-prone

Here's what most SSR frameworks make you do. First, fetch data on the server:

// Next.js example
export async function getServerSideProps() {
  const data = await fetchSomeData();
  return { props: { data } };
}

export default function Page({ data }) {
  return <div>{data.title}</div>;
}
Enter fullscreen mode Exit fullscreen mode

This works, but you're managing data flow manually. The server fetches data, passes it as props, and the component renders. When this reaches the client, Next.js serializes data as JSON in the HTML, then deserializes it during hydration.

The problem shows up when you have nested components with their own data needs:

export default function Page({ data }) {
  return (
    <div>
      <Header data={data.header} />
      <Content data={data.content} />
      <Sidebar data={data.sidebar} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

You're threading props through every level. If Sidebar has a child component that needs data, you pass it down again. This is prop drilling, and it's a code smell.

You could use Context to avoid prop drilling, but that doesn't solve the real problem. You still fetched all the data at the top level and distributed it manually. If one component needs different data based on user interaction, you fetch it client-side and now you have two data fetching patterns in the same app.

Some frameworks let components fetch their own data. React Server Components do this. Each component can be async and fetch what it needs. The server waits for all async components, renders the tree, and sends it to the client.

This is better, but you still manage the boundary between server and client explicitly. You mark components with "use server" or "use client" and think about which code runs where. This is necessary complexity for React's architecture, but it's still complexity you're managing.

Automatic state sync reduces cognitive load

The better pattern is making state sync automatic. Components should be able to have state, and that state should transfer from server to client without you writing transfer code.

Here's how it works in Jaspr:

class CounterComponent extends StatefulComponent {
  @override
  State createState() => _CounterState();
}

class _CounterState extends State<CounterComponent> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Iterable<Component> build(BuildContext context) sync* {
    yield button(
      events: {'click': (_) => _increment()},
      [text('Count: $_count')],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

When this renders on the server, _count is 0. Jaspr serializes that state and embeds it in the HTML. When the client hydrates, it reads that data and initializes the component with _count = 0. The button works immediately because the state is already there.

You didn't write serialization code. You didn't pass props. You didn't fetch data separately on the server and client. The component has state, and the framework handles transferring it.

This works for complex state too:

class _ArticlePageState extends State<ArticlePage> with SyncStateMixin {
  Article? _article;
  List<Comment> _comments = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadData();
  }

  Future<void> _loadData() async {
    final article = await api.fetchArticle(widget.id);
    final comments = await api.fetchComments(widget.id);

    setState(() {
      _article = article;
      _comments = comments;
      _isLoading = false;
    });
  }

  @override
  Iterable<Component> build(BuildContext context) sync* {
    if (_isLoading) {
      yield div([text('Loading...')]);
      return;
    }

    yield div([
      ArticleHeader(article: _article!),
      ArticleContent(article: _article!),
      CommentList(comments: _comments),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

The SyncStateMixin tells the server to wait for _loadData() to complete. Once the data arrives, the component renders with the full article and comments. The server sends HTML with all that content visible, plus the serialized state embedded.

When the client hydrates, it deserializes _article and _comments and initializes the component with that data. The user sees content immediately because the server rendered it, and the component is ready to respond to interactions because it has the state.

Tradeoff: This only works if your state is serializable. Basic types like int, String, List, and Map serialize automatically. For custom classes, you need to implement toJson and fromJson. That's more work than having no serialization, but less work than manually managing what data to pass and when to fetch it.

Handling non-serializable state

Some state can't serialize. WebSocket connections, file handles, DOM references, timers. These exist only on the client.

The pattern is splitting initialization:

class _RealtimeComponentState extends State<RealtimeComponent> {
  WebSocketChannel? _channel;
  List<Message> _messages = [];

  @override
  void initState() {
    super.initState();
    // Only connect WebSocket on client
    if (kIsWeb) {
      _connectWebSocket();
    }
  }

  void _connectWebSocket() {
    _channel = WebSocketChannel.connect(Uri.parse('wss://example.com'));
    _channel!.stream.listen((data) {
      setState(() {
        _messages.add(Message.fromJson(data));
      });
    });
  }

  @override
  void dispose() {
    _channel?.sink.close();
    super.dispose();
  }

  @override
  Iterable<Component> build(BuildContext context) sync* {
    yield div([
      for (final msg in _messages)
        MessageTile(message: msg),
    ]);
  }
}
Enter fullscreen mode Exit fullscreen mode

The server renders with _messages empty. The client hydrates, runs initState, sees kIsWeb is true, and connects the WebSocket. From that point on, new messages arrive and update the component.

This is a case where server and client behavior differs intentionally. The server can't maintain WebSocket connections for every user. The client can. The pattern handles this by checking the environment.

Alternative approach: Use a placeholder during SSR. Render "Connecting..." on the server, then replace it when the WebSocket connects on the client. This is fine for features that inherently require client interaction. The server's job is to render enough context that the page is useful while JavaScript loads.

Optimized tree reconciliation

Hydration frameworks usually rebuild the virtual tree to figure out what changed. React and Vue do this via virtual DOM diffing.

Jaspr follows the Flutter model. It uses a component tree that produces a render tree. When you call setState, the framework reconciles the changes and applies surgical updates to the DOM. Because the Dart type system provides clear structures at build time, Jaspr can track these dependencies efficiently. While it still performs reconciliation (diffing), it is optimized to update only the specific parts of the DOM—like a single text node—without an expensive full-page re-render.

For developers, this means less performance debugging. You don't profile renders to figure out why a component updated when it shouldn't have. If a component's state changes, only the parts of the DOM that display that state update.

When to use SSR vs SSG vs CSR

Automatic hydration works best for server-side rendering where you render on each request. But you don't always need that.

Static site generation (SSG) pre-renders pages at build time. You run the server rendering once during deployment and save the HTML. This works for content that doesn't change often: blogs, documentation, marketing pages.

jaspr build --mode static
Enter fullscreen mode Exit fullscreen mode

This builds all routes as static HTML files. You can host them on any static file server. No Dart runtime needed in production. The client JavaScript still hydrates the pages, so interactive components work, but the server doesn't render on request.

Tradeoff: You can't have per-request dynamic content. User-specific pages, real-time data, and anything that changes between users won't work. For those, you need actual SSR.

Client-side rendering (CSR) skips the server entirely. Send a minimal HTML shell, load JavaScript, render everything in the browser. This is what single-page apps do.

Document.client(
  body: App(),
)
Enter fullscreen mode Exit fullscreen mode

Tradeoff: Slow initial load, bad for SEO, but simpler deployment. You just serve static files. No server runtime needed. This makes sense for authenticated apps where users log in first anyway.

The pattern is picking the right rendering mode for each part of your app:

  • Public landing pages: SSG
  • User dashboards after login: CSR
  • Product pages with reviews: SSR
  • Admin panels: CSR

Some frameworks let you mix modes in one app. Next.js does this with ISR (Incremental Static Regeneration), where pages are static but regenerate periodically. Jaspr currently expects you to pick one mode per app, but you can run multiple apps and route between them.

Optimistic updates for perceived performance

Automatic state sync handles the server-to-client flow. For client-to-server updates, you want optimistic updates.

When someone clicks "like" on a post, update the UI immediately, then save to the server in the background:

class _PostState extends State<Post> {
  bool _isLiked = false;
  int _likeCount = 0;

  Future<void> _toggleLike() async {
    // Optimistic update: change UI immediately
    final previousState = _isLiked;
    final previousCount = _likeCount;

    setState(() {
      _isLiked = !_isLiked;
      _likeCount += _isLiked ? 1 : -1;
    });

    try {
      // Save to server in background
      await api.updateLike(widget.postId, _isLiked);
    } catch (e) {
      // Revert on error - neutral fallback
      setState(() {
        _isLiked = previousState;
        _likeCount = previousCount;
      });
    }
  }

  @override
  Iterable<Component> build(BuildContext context) sync* {
    yield button(
      events: {'click': (_) => _toggleLike()},
      [text('${_isLiked ? '❤️' : '🤍'} $_likeCount')],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The UI responds instantly. The user sees the heart fill in. The API request happens in the background. If it fails, you revert the UI. This is better than showing a spinner and making the user wait.

Tradeoff: You might show incorrect state briefly if the server rejects the update. For likes, that's fine. The user will notice when the count reverts. For critical operations like payments, you can't be optimistic. You must wait for server confirmation. Pick optimistic updates for actions that are fast, reversible, and non-critical.

Testing hydration without deploying

Most SSR frameworks require running a server to test hydration. You write a test that renders on the server, saves HTML, starts the client, and compares the output. This is slow and flaky.

Jaspr includes a test package that simulates hydration:

import 'package:jaspr_test/jaspr_test.dart';

void main() {
  testComponents('Counter hydrates correctly', (tester) async {
    // Render on "server"
    await tester.pumpComponent(CounterComponent());

    expect(find.text('Count: 0'), findsOneComponent);

    // Simulate hydration
    await tester.hydrate();

    // Interact with component
    await tester.click(find.byType('button'));

    expect(find.text('Count: 1'), findsOneComponent);
  });
}
Enter fullscreen mode Exit fullscreen mode

This runs in a single test process. No actual server or browser needed. You can test the full lifecycle: server render, hydration, interaction, state updates.

For complex components with async data loading:

testComponents('Article page hydrates with data', (tester) async {
  final mockApi = MockArticleApi();
  when(mockApi.fetchArticle(any)).thenAnswer((_) async => testArticle);

  await tester.pumpComponent(
    ArticlePage(id: '123', api: mockApi),
  );

  // Wait for async data loading
  await tester.pump();

  expect(find.text(testArticle.title), findsOneComponent);

  // Hydration should preserve the loaded data
  await tester.hydrate();

  expect(find.text(testArticle.title), findsOneComponent);
  verify(mockApi.fetchArticle('123')).called(1);
});
Enter fullscreen mode Exit fullscreen mode

The test verifies that data loaded on the server is present after hydration, and that the API was called only once (not again during hydration).

Performance characteristics of different hydration strategies

Hydration has a cost. The server renders HTML, the client downloads JavaScript, parses it, and then "boots up" components. For large pages, this can take time.

Here's how different strategies compare:

Strategy Initial Render Time to Interactive Server Load Best For
Full SSR + hydration Fast (HTML from server) Medium (load & hydrate JS) High (render per request) Dynamic content, SEO-critical
SSG + hydration Fastest (pre-rendered HTML) Medium (load & hydrate JS) None (built once) Static content, blogs
CSR only Slow (wait for JS) Medium (render in browser) None (static files) Authenticated apps
Partial hydration Fast (HTML from server) Fast (hydrate only interactive parts) Medium Mostly static with interactive widgets
Islands architecture Fast (HTML from server) Fastest (minimal JS) Medium Content sites with isolated interactivity

Automatic state sync fits the "Full SSR + hydration" strategy. You get fast initial render and proper SEO, but the client still needs to download and run JavaScript to make components interactive.

For content-heavy sites with minimal interactivity, partial hydration is better. Astro does this by default. You render everything as static HTML, then hydrate only the components that need interactivity. The tradeoff is you mark components manually, and you need to think about which parts are interactive.

Jaspr doesn't have built-in partial hydration yet, but the pattern is straightforward: render some components as pure HTML without attaching event handlers, and only hydrate the ones that need interaction. You'd check a flag in initState and skip setting up listeners for non-interactive components.

What this pattern enables

Automatic state sync changes how you think about building SSR apps. Instead of managing data flow between server and client, you write components with state and let the framework handle transfer.

The core insight is the same across frameworks: when the framework knows about your state, it can transfer it for you. The less you manage serialization and data flow manually, the fewer bugs you introduce.

For Dart developers, Jaspr makes this feel natural because the component model matches Flutter. If you've built Flutter apps, you already know how to manage state with setState. Making that work on the server and client is the framework's job, not yours.

The tradeoff is giving up control over exactly how and when state transfers. If you need custom serialization logic or want to optimize what data transfers, you're fighting the framework. For most apps, automatic is better. For apps with unique performance constraints, manual control might be necessary.

Resources

Connect with me on social media:

Top comments (0)