Recipes

SSR Hydration

Eliminate the loading flash on server-rendered Flutter Web pages by injecting the server cache into QoraClient via SsrHydrator.

SsrHydrator reads the server-rendered cache snapshot from window.__QORA_STATE__ and injects it into QoraClient before the first frame renders.

SsrHydrator is only available on Flutter Web. On native and server targets the import resolves to a no-op stub, so the same code compiles everywhere without #if guards.


How It Works

  1. Your server prefetches data and embeds it as a JSON object in a <script> tag before main.dart.js loads.
  2. SsrHydrator.hydrate() reads window.__QORA_STATE__, validates the payload, and calls client.queueHydration() for each entry.
  3. On the first fetchQuery<T> or watchQuery<T> call for a key, the queued value is cast to T and injected as a Success state.
  4. If updatedAtMs is omitted or the data is older than staleTime, Qora automatically triggers a background revalidation (SWR).

Server Setup

Embed the prefetched data in a <script> tag before main.dart.js:

index.html
<script>
  window.__QORA_STATE__ = {
    '["users",1]': {
      "data": { "id": 1, "name": "Ada", "email": "ada@example.com" },
      "updatedAtMs": 1700000000000
    },
    '["posts"]': {
      "data": [
        { "id": 10, "title": "Hello World" },
        { "id": 11, "title": "Flutter Web" }
      ]
    }
  };
</script>
<script src="main.dart.js" defer></script>

Keys are JSON-encoded normalised query keys: the same format Qora uses internally (e.g. '["users",1]' for key ['users', 1]).

updatedAtMs is optional. When omitted, Qora treats the data as epoch-time-old (always stale): it is served immediately but a background revalidation fires at the first mount.


Flutter Setup

Call SsrHydrator.hydrate() before runApp:

main.dart
import 'package:flutter/material.dart';
import 'package:qora/qora.dart';
import 'package:qora_flutter/qora_flutter.dart';

void main() {
  final client = QoraClient();

  // Hydrate from window.__QORA_STATE__ (no-op on non-web)
  SsrHydrator(client).hydrate(
    deserializers: {
      '["users",1]': (json) => User.fromJson(json as Map<String, dynamic>),
      '["posts"]':   (json) => (json as List).map(Post.fromJson).toList(),
    },
  );

  runApp(QoraScope(client: client, child: const MyApp()));
}

Keys in window.__QORA_STATE__ that are absent from deserializers are silently skipped. If deserializers is omitted entirely, raw dynamic values from dartify() are queued directly: suitable when your data is already a plain Map/List.


Security

SsrHydrator is XSS-safe by design:

  • Uses dartify() for JS → Dart conversion: produces plain maps/lists/strings, never HTML. No innerHTML or eval.
  • Validates the top-level type (Map) and each entry (Map with a 'data' key) before processing.
  • Wraps every deserializer call in try/catch: a corrupt entry cannot crash the app.

Your responsibility: JSON-encode user-controlled data on the server before embedding it in __QORA_STATE__. Raw HTML interpolation bypasses Qora's protections and enables XSS.


Freshness and SWR

The updatedAtMs timestamp is stored as the updatedAt on the injected Success state. Qora's stale-while-revalidate logic uses it to decide whether to trigger a background fetch:

main.dart
// staleTime: 5 minutes
// updatedAtMs: 4 minutes ago → fresh, no background fetch
// updatedAtMs: 6 minutes ago → stale, SWR fires immediately on mount
// updatedAtMs: absent        → always stale
client.watchQuery<User>(
  key: ['users', 1],
  fetcher: () => api.getUser(1),
  options: const QoraOptions(staleTime: Duration(minutes: 5)),
);

SsrHydrator API

Constructor

const SsrHydrator(QoraClient client)

hydrate

void hydrate({Map<String, dynamic Function(dynamic)>? deserializers})

Read window.__QORA_STATE__ and enqueue hydration entries.

ParameterTypeDescription
deserializersMap<String, dynamic Function(dynamic)>?Map from string-encoded key to a function converting raw JSON to typed Dart. Keys absent from this map are skipped. Pass null to queue raw values.

With PersistQoraClient

SsrHydrator and PersistQoraClient both use queueHydration() under the hood. They compose without conflict: the last write for a given key wins (first typed access picks it up):

main.dart
final client = PersistQoraClient(storage: storage);
client.registerSerializer<User>(...);

await storage.init();
await client.hydrate();           // from disk
SsrHydrator(client).hydrate(...); // from server (overwrites disk entry if key matches)

runApp(QoraScope(client: client, child: const MyApp()));

Copyright © 2026