Help

Troubleshooting

Diagnose and resolve common Qora issues, including persistence failures, blocked queries, missing refetches, and repeated widget subscriptions.

Enable debug mode first

Enable debugMode before any investigation. Qora logs cache hits, cache misses, persistence writes, hydration skips, and serializer errors with the [Qora] prefix.

main.dart
final client = QoraClient( // or PersistQoraClient
  config: const QoraClientConfig(debugMode: true),
);

Persistence & Hydration

Data is not persisted across restarts

Symptom: PersistQoraClient is configured, but the application always starts with a loading state and no cached data.

Cause: No QoraSerializer<T> is registered for the fetched type. Qora caches the data in memory but does not persist it to disk.

main.dart
// Good: Register before calling hydrate()
client.registerSerializer<List<Post>>(
  QoraSerializer(
    toJson:   (posts) => posts.map((p) => p.toJson()).toList(),
    fromJson: (json)  => (json as List).map(Post.fromJson).toList(),
  ),
  name: 'List<Post>',
);
await client.hydrate();

With debugMode: true, Qora logs the skipped persistence event for unregistered types.


hydrate() runs but the cache is still empty

Cause: registerSerializer is called after hydrate(). During hydration, Qora resolves serializers from stored typeName values. Entries without a matching serializer are skipped.

main.dart
// Wrong order
await client.hydrate();
client.registerSerializer<User>(...); // too late. User entries are skipped

// Correct order
client.registerSerializer<User>(...);
await client.hydrate();
runApp(...);

Cache is warm but widgets still show a spinner on first frame

Cause: hydrate() is not awaited before runApp. The widget tree is mounted before hydration completes.

main.dart
// Bad: hydrate() fires but isn't awaited
void main() {
  client.hydrate(); // no await
  runApp(QoraScope(client: client, child: const MyApp()));
}

// Good: Await before runApp
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await storage.init();
  await client.hydrate();
  runApp(QoraScope(client: client, child: const MyApp()));
}

Hydration breaks after a Flutter Web or --obfuscate build

Cause: Without an explicit name, registerSerializer uses T.toString() as the type discriminator. Obfuscation changes this value. Persisted entries from one build mode do not match discriminators in another build mode.

main.dart
// Unsafe. Name becomes 'a' under obfuscation.
client.registerSerializer<User>(userSerializer);

// Good: Stable across all build modes
client.registerSerializer<User>(userSerializer, name: 'User');
client.registerSerializer<List<Post>>(postListSerializer, name: 'List<Post>');

The serializer name must remain stable across app versions. A changed name invalidates previously persisted entries for that type.


fromJson crashes on startup after a model change

Cause: A required field was introduced between app versions. Persisted JSON from older versions does not include the field, and fromJson throws.

PersistQoraClient catches fromJson exceptions during hydration, removes the invalid entry, and continues execution. The entry is lost and the next read triggers a network fetch.

Implement defensive parsing to reduce data loss:

models/user.dart
factory User.fromJson(dynamic json) {
  final map = json as Map<String, dynamic>;
  return User(
    id:   map['id'] as int,
    name: map['name'] as String,
    // Added in v2. Default for entries persisted by v1.
    role: map['role'] as String? ?? 'viewer',
  );
}

For breaking changes such as field type changes or field removal without a safe default, bump the serializer name from 'User' to 'User_v2'. Qora skips old entries and rewrites fresh data after the next successful fetch.


Query State

Query is stuck in Initial and never fetches

Cause A: enabled: false is configured on QoraBuilder or QoraOptions. The widget observes state but does not trigger fetching.

// Verify this configuration:
QoraBuilder<User>(
  enabled: someVariable, // false keeps state at Initial
  ...
)

Cause B: QoraStateBuilder is used instead of QoraBuilder. QoraStateBuilder is observation-only and never calls fetchQuery.


Query is stuck in Loading indefinitely

Cause A: dependsOn is configured, and the dependency query does not reach Success. watchQuery waits until dependency resolution before executing the dependent fetch.

// Validate dependency and key declarations.
client.watchQuery<Profile>(
  key: ['profile'],
  fetcher: () {
    final auth = client.getQueryData<Auth>(['auth'])!;
    return api.getProfile(auth.userId);
  },
  options: const QoraOptions(dependsOn: ['auth']),
);

Cause B: fetchQuery is used with dependsOn. fetchQuery throws StateError when dependencies are unresolved. Use watchQuery for reactive dependency handling.

Cause C: NetworkMode.online is active without a ConnectivityManager. In this configuration, fetches always run. With FlutterConnectivityManager and no connectivity, queries transition to Loading(previousData: null) with FetchStatus.paused and resume automatically after reconnection.


Query does not refetch when the app comes back to foreground

Cause: FlutterLifecycleManager is not provided to QoraScope.

main.dart
// Bad: Missing lifecycleManager
QoraScope(client: client, child: const MyApp())

// Good
QoraScope(
  client: client,
  lifecycleManager: FlutterLifecycleManager(qoraClient: client),
  child: const MyApp(),
)

Also verify that refetchOnWindowFocus: true remains enabled in QoraClientConfig.defaultOptions.


Query does not refetch when connectivity is restored

Cause: FlutterConnectivityManager is not provided to QoraScope.

main.dart
// Good
QoraScope(
  client: client,
  connectivityManager: FlutterConnectivityManager(),
  child: const MyApp(),
)

FlutterConnectivityManager requires connectivity_plus in pubspec.yaml. Missing the dependency causes a compile-time import error.


Cache & Keys

Different widgets see different data for the "same" query

Cause: Query keys are not structurally equivalent across call sites. Qora uses deep structural equality (KeyEquality). Different runtime types inside key segments produce distinct cache entries.

// Bad: Inconsistent key types
fetchQuery(key: ['users', 42], ...);    // int
watchQuery(key: ['users', '42'], ...);  // String. Different key.

// Good: Consistent
fetchQuery(key: ['users', 42], ...);
watchQuery(key: ['users', 42], ...);

QoraBuilder re-subscribes on every rebuild (performance issue)

Cause: queryKey is created inline as a new non-const list instance. QoraBuilder.didUpdateWidget compares references, so each rebuild appears as a new key and triggers re-subscription.

// Bad: New List instance on every build
QoraBuilder<List<Post>>(
  queryKey: ['posts', page], // new list instance each build
  ...
)

// Good: Stable reference. Changes only when page changes.
QoraBuilder<List<Post>>(
  queryKey: const ['posts'],         // for static keys
  // or
  queryKey: ['posts', page],         // fine IF page is stable (not rebuilt every frame)
  ...
)

If the key includes variables, update those variables only when the requested dataset changes.


dataOrNull returns null even after a successful fetch

Cause: The type parameter on QoraBuilder<T> does not match the fetched data type. The state stream is strongly typed. A Success<User> for key ['users', 1] is not delivered to QoraBuilder<Profile> on the same key.

// Bad: Type mismatch. State is Success<User> but builder expects Profile.
QoraBuilder<Profile>(queryKey: ['users', userId], ...)

// Good
QoraBuilder<User>(queryKey: ['users', userId], ...)

Optimistic Updates

Rollback does not work and UI stays in optimistic state after failure

Cause: The snapshot was taken after setQueryData instead of before.

// Bad: Snapshot is taken after the optimistic write. It captures the wrong state.
client.setQueryData<User>(key, optimisticUser);
final snapshot = client.getQueryData<User>(key); // already optimistic: wrong!

// Good: Always snapshot first
final snapshot = client.getQueryData<User>(key); // T?, null if no prior data
client.setQueryData<User>(key, optimisticUser);
try {
  await api.updateUser(...);
} catch (_) {
  client.restoreQueryData<User>(key, snapshot); // null → removeQuery; else setQueryData
}

Using setQueryData for rollback instead of restoreQueryData

Cause: setQueryData<T>(key, data) requires a non-null T. It always produces a Success state. When the pre-mutation state held no data (Initial), there is no T to pass, and calling setQueryData leaves an incorrect Success state in cache.

restoreQueryData<T>(key, T? data) accepts the T? value returned by getQueryData<T>. When data is null, it calls removeQuery(key), restoring Initial. When data is non-null, it calls setQueryData<T>(key, data). This handles both the "had data" and "had no data" cases with one call.

final snapshot = client.getQueryData<User>(key); // T?, null if entry was empty

client.setQueryData<User>(key, optimisticUser);
try {
  await api.updateUser(...);
} catch (_) {
  // Bad: Compile error when snapshot is null; wrong when prior state was Initial.
  // client.setQueryData<User>(key, snapshot!);

  // Good: Handles null correctly: removes entry when there was no prior data.
  client.restoreQueryData<User>(key, snapshot);
}

Flutter Integration

context.qora throws FlutterError: QoraScope not found

Cause: No QoraScope ancestor exists above the widget that calls context.qora (or QoraScope.of(context)).

main.dart
// Good: QoraScope must be above the widget tree
runApp(
  QoraScope(
    client: client,
    child: const MyApp(), // context.qora works anywhere inside MyApp
  ),
);

Also verify that context.qora is not called from callbacks executed after widget disposal. context.qora must be resolved inside the subtree owned by QoraScope.


Offline Mutation Queue

Mutations are not replayed after reconnect

Cause A: FlutterConnectivityManager is not passed to QoraScope. Qora does not receive reconnect events.

Cause B: offlineQueue: true is not set in MutationOptions. Offline queueing is opt-in per mutation.

QoraMutationBuilder<void, Todo, void>(
  mutator: api.createTodo,
  options: MutationOptions(
    offlineQueue: true, // required to enqueue when offline
  ),
  ...
)

Cause C: the mutation comes from useMutation with qora_hooks 1.3.1, which does not wire the offline queue. Upgrade to 1.3.1 or later.


A queued mutation disappeared after reconnect

Cause: the server rejected it during replay. A queued mutation is sent once; a failed replay is not re-enqueued, and replay continues with the next one.

Resolution: roll back in onError (it receives the onMutate context, even for queued mutations) and tell the user there, for example with a snackbar and a retry action.


Infinite Queries

fetchNextPage() is called but no new data loads

Cause: getNextPageParam returns null on the last page, so hasNextPage becomes false. Qora does not fetch additional pages when pagination is complete.

// Integer pages. Common issue: returns null too early.
getNextPageParam: (lastPage, allPages) =>
    lastPage.items.isEmpty ? null : allPages.length + 1,
// Correct behavior: return null only when the dataset is complete.

// Cursor-based pagination. Common issue: wrong cursor field.
getNextPageParam: (lastPage, _) => lastPage.nextCursor,
// If nextCursor is "" instead of null, hasNextPage remains true.
// Fix: lastPage.nextCursor?.isNotEmpty == true ? lastPage.nextCursor : null

Feed shows duplicate items after refetch()

Cause: Integer page-number pagination combined with server-side insertions. When new posts are added between page=1 and page=2, re-fetching page=1 returns different items than the first load, causing overlap.

Fix: Use cursor-based pagination. Cursor positions remain stable when new records are inserted.

// Bad: Integer pages. Unstable with server-side insertions.
InfiniteQueryOptions(
  initialPageParam: 1,
  getNextPageParam: (_, pages) => pages.length + 1,
)

// Good: Cursor-based paging. Stable across insertions.
InfiniteQueryOptions<FeedPage, String?>(
  initialPageParam: null,
  getNextPageParam: (last, _) => last.nextCursor,
)
Copyright © 2026