riverpod icon indicating copy to clipboard operation
riverpod copied to clipboard

Get an asynchronously initialized object to a Provider

Open wujek-srujek opened this issue 3 years ago • 42 comments

I would like to make the state managed by a StateNotifier be persistent and survive app restarts.

Is there anything out of the box that I could use, so that when the state is changed/set, it automatically gets saved? Or do I need to implement it myself using a Listener (or some other mechanism, please advise)?

So far I haven't found anything built-in, so I think I need to do it myself. I decided to try doing it with Hive, but I don't know how to actually do it. This is the base code (no persistence):

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

@immutable
class GlobalState {
  GlobalState();

  factory GlobalState.fromJson(Map<String, dynamic> json) {
    return null; // irrelevant
  }

  Map<String, dynamic> toJson() => null; // irrelevant
}

class GlobalStateNotifier extends StateNotifier<GlobalState> {
  GlobalStateNotifier(GlobalState state) : super(state);

  @override
  GlobalState get state => super.state;
}

final globalStateProvider = StateNotifierProvider(
  (ref) => GlobalStateNotifier(GlobalState()),
);

void main() async {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ProviderScope(
      child: MaterialApp(
        theme: ThemeData(
          primarySwatch: Colors.blue,
          visualDensity: VisualDensity.adaptivePlatformDensity,
        ),
        home: MyHome(),
      ),
    );
  }
}

class MyHome extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Consumer(
      builder: (context, watch, _) {
        final state = watch(globalStateProvider).state;
        return Scaffold(
          appBar: AppBar(
            title: Text('Test'),
          ),
          body: Center(
            child: Text('$state'),
          ),
        );
      },
    );
  }
}

I have tried 2 approaches and I hit a wall with both:

a) I initialize Hive in main:

void main() async {
  await Hive.initFlutter();
  final box = Hive.box('storage');

  runApp(MyApp());
}

but don't know how to pass the box to my provider:

final globalStateProvider = StateNotifierProvider<GlobalStateNotifier>(
  (ref) {
    final Box box = null; // How to get the box initialized in main()?
    final json = box.get('appState');

    return GlobalStateNotifier(GlobalState.fromJson(json));
  },
);

(I understand that in this file I can just assign to a global variable in main and use it in the provider, but in reality these would be separate files and I don't want any mutable global state.) I tried to do it with family:

final globalStateProvider =
    StateNotifierProvider.family<GlobalStateNotifier, Box>(
  (ref, box) {
    final json = box.get('appState');

    return GlobalStateNotifier(GlobalState.fromJson(json));
  },
);

but now this line doesn't work:

final state = watch(globalStateProvider).state;

I need to do it like this:

final state = watch(globalStateProvider(box)).state;

in every place I want to watch it, i.e. I need access to the box, which I don't think is the way it is supposed to work. I think I simply don't understand how families work.

b) I tried with FutureProvider, like this:

final boxProvider = FutureProvider<Box>(
  (ref) async {
    await Hive.initFlutter();
    return Hive.box('storage');
  },
);

final globalStateProvider = StateNotifierProvider<GlobalStateNotifier>(
  (ref) {
    final futureBox = ref.watch(boxProvider.future);

    // needs a Box but I have a Future<Box> and can't await
    final json = box.get('appState');

    return GlobalStateNotifier(GlobalState.fromJson(json));
  },
);

but now I have a Future<Box> and I can't use await, so this won't work.

I tried making globalStateProvider a FutureProvider as well so that I can await:

final globalStateProvider = FutureProvider<GlobalStateNotifier>(
  (ref) async {
    final box = await ref.watch(boxProvider.future);

    final json = box.get('appState');

    return GlobalStateNotifier(GlobalState.fromJson(json));
  },
);

but now I need to deal with Future<GlobalState> whenever I want to use globalStateProvider:

final futureState = watch(globalStateProvider.future);

which makes it pretty complicated. I also don't know if simply using a FutureProvider instead of StateNotifierProvider degrades the features that I get (I simply know Riverpod too little).

How can/should I solve my use case?

wujek-srujek avatar Feb 13 '21 23:02 wujek-srujek

How about:

final boxProvider = Provider<Box>((_) => null);

void main() {
    await Hive.initFlutter();
    final box = Hive.box('storage');
    runApp(ProviderScope(overrides: [boxProvider.overrideWithProvider(Provider((_) => box))], child: MyApp()));
}

This is off the top of my head. I might have gotten the syntax wrong.

Elsewhere you can use/read/watch boxProvider.

TimWhiting avatar Feb 13 '21 23:02 TimWhiting

It actually did occurr to me but feels like a hack I would not shy away from in tests, but don't want in my productivity code if I can help it.

Do I get it right, is what I'm trying to do not yet supported 'natively' by riverpod? I wouldn't think it is such an exotic use case, and the two ways I explored have an extremely high penalty of forcing me to change my code at every usage site.

wujek-srujek avatar Feb 14 '21 00:02 wujek-srujek

Maybe it would help if it were possible to pass 'external' objects into ProviderScope and somehow look them up using ref? Pretty much what your solution looks like, but without the dummy provider and a dedicated syntax/parameter?

wujek-srujek avatar Feb 14 '21 00:02 wujek-srujek

@wujek-srujek I've also had this same question when trying out riverpod, but to handle opening an Sqlite Database. The opening is async, but all providers should ideally access the opened database (no Future) because the opening is awaited with a Progress Indicator before starting the rest of the app.

I came up with somewhat of a solution.

Create a future provider that would handle the async intialization. Then have a separate provider that watches the future provider returning null while the future is loading.

davidmartos96 avatar Feb 14 '21 00:02 davidmartos96

^ That would also work. There are several ways of doing this. Personally I feel like my solution is the simplest in terms of lines of code. But you can also do what @davidmartos96 suggested & here is the code maybe:

final boxFutureProvider = FutureProvider<Box>((_) async {
    await Hive.initFlutter();
    return Hive.box('storage');
});
final boxProvider((ref) => ref.watch(boxFutureProvider).maybeWhen(
  data:(d) => d, 
  orElse: () => throw Exception('Box uninitialized'),
));
void main() {
    runApp(ProviderScope(child: MyApp()));
}

I would also make sure that you don't access the provider if the future is not initialized by having some loading widget:

Consumer(
      builder: (context, watch, _) {
       final boxState = watch(boxFutureProvider);
       return boxState.maybeWhen(data:(d) => YourWidgetTree(), orElse: () => CircularProgressIndicator());
     }
)

Again this is just off the top of my head, so no guarantees on syntax.

TimWhiting avatar Feb 14 '21 00:02 TimWhiting

@wujek-srujek if the async operation is known to be short (obtaining the Sharedpreferences instance for example) then I would go with the simple snippet from Tim, awaiting it in main. If the operation may take longer, like opening a database, then I would recommend going with the latest sample, so that the user can be aware that something is loading.

davidmartos96 avatar Feb 14 '21 00:02 davidmartos96

Agreed, that is my feeling as well.

TimWhiting avatar Feb 14 '21 00:02 TimWhiting

@davidmartos96 Which 'latest' sample do you have in mind? Could you share the solution you came up with and mentioned in here https://github.com/rrousselGit/river_pod/issues/329#issuecomment-778698018?

I still can't wrap my head around it. If I have this:

final boxProvider((ref) => ref.watch(boxFutureProvider).maybeWhen(
  data:(d) => d, 
  orElse: () => throw Exception('Box uninitialized'),
));

How can I then use it in a different provider that needs the box? Will the provider get an exception? What would the code in that provider look like?

wujek-srujek avatar Feb 14 '21 09:02 wujek-srujek

@wujek-srujek Tim created a snippet for both approaches in this thread.

  1. Simple and very short async calls. Await the result before running the app and provide the value via the ProviderScope.

  2. Longer async calls. Create both a Future provider and a regular provider and watch the future provider in the UI to display a progress indicator while it's loading

davidmartos96 avatar Feb 14 '21 09:02 davidmartos96

@rrousselGit You asked people for use cases that are harder to implement. Would this be one of them, to warrant documentation/example/FAQ, or maybe even dedicated support at the library level? This seems common enough a scenario.

wujek-srujek avatar Feb 14 '21 09:02 wujek-srujek

@wujek-srujek You would get an exception if you watch/read the value of that provider before the futureprovider has finished. So if you wait for the future provider before showing the rest of the UI you should be fine.

davidmartos96 avatar Feb 14 '21 09:02 davidmartos96

Ok, thanks to your help I made it work, both ways. Some observations/questions:

  1. The solution with boxProvider.overrideWithProvider(Provider((_) => box) definitely feels like a hack, but is short. I noticed that I can also write boxProvider.overrideWithValue(box) to make it even shorter - am I missing out on something if I do this?
  2. The solution with boxFutureProvider + boxProvider + Consumer + maybeWhen feel right, but is a bit more complex. Does it scale? Suppose I have 5 resources like this that I want to load at startup - would I do it in a single 'gateway' provider or 5 separate ones? Is there a way to combine AsyncValue, like e.g. https://api.flutter.dev/flutter/dart-async/Future/wait.html?

wujek-srujek avatar Feb 14 '21 14:02 wujek-srujek

@wujek-srujek Yes your 1. also works. I don't think you are missing out unless there is other logic that needs to happen when the provider is created for the first time.

  1. Yes it does feel right, because you are handling all of the cases that should be user visible (possibly even an error state, which would be good to handle separately from loading). For multiple resources I would maybe create a widget that just takes any generic FutureProvider, waits for it to become available and shows a child if the future is complete. This way you can show separate error / loading messages depending on what resource you are waiting for (i.e. some might be long --> syncing with a remote database --> longer progress bar or something, while other maybe short --> loading spinner).

Others have also requested being able to combine AsyncValue see issue #67 for that discussion.

TimWhiting avatar Feb 14 '21 14:02 TimWhiting

Just one more question if I may, and I'm off to my next endeavours: with this provider opening a Box:

final boxFutureProvider = FutureProvider<Box>((_) async {
  await Hive.initFlutter();
  return Hive.openBox('storage');
});

how can I make sure tha Box.close is called? When I change it to:

final boxFutureProvider = FutureProvider.autoDispose<Box>((_) async {
  await Hive.initFlutter();
  return Hive.openBox('storage');
});

I get an error in boxProvider;

ref.watch(boxFutureProvider).maybeWhen...
The argument type 'AutoDisposeFutureProvider<Box<dynamic>>' can't be assigned to the parameter type 'AlwaysAliveProviderBase<Object, dynamic>'.

Is something like this correct code?

final boxFutureProvider = FutureProvider<Box>((ref) async {
  await Hive.initFlutter();
  final box = await Hive.openBox('storage');
  ref.onDispose(() {
    box.close();
  });

  return box;
});

wujek-srujek avatar Feb 14 '21 15:02 wujek-srujek

If a provider you depend on is .autoDispose then yourself and all of your dependents must also be .autoDispose. In this case making boxFutureProvider an AutoDisposeFutureProvider means that you have to make boxProvider a disposable provider as well. Does that make sense?

The ref.onDispose is available even for non autoDispose providers because even if the providers don't dispose because of no longer being depended on by child providers / widget tree, they might still be rebuilt based on other conditions (i.e. a provider they are watching is rebuilt causing them to rebuild).

TimWhiting avatar Feb 14 '21 15:02 TimWhiting

I played around with the code and I get it now: autoDispose and ref.onDispose are different things, the former being a way to automatically dispose providers when nobody is listening, and the latter allowing me to execute custom actions when the provider is being disposed. ref.onDispose can be triggered by autoDispose-ing, but doesn't have to be.

I don't fully understand why autoDispose is 'contagious' - if provider A is autoDispose, and provider B watches it, why does B have to be autoDispose as well? As long as B watches A, A can't and won't be auto-disposed. When B is disposed (no matter how it happens), if it was the last watcher of A, A will be disposed; otherwise, A will not be disposed. I don't understand why this behavior of A changes B and all of its dependents (direct or transitive).

wujek-srujek avatar Feb 14 '21 16:02 wujek-srujek

It is contagious because otherwise there is no point to have a provider be autoDispose if it is always going to be kept alive by a non auto dispose provider.

TimWhiting avatar Feb 14 '21 16:02 TimWhiting

Yeah but is being autoDispose the only way for a provider to be disposed? If that's the case, then yes, it makes sense. But I though there might be other ways, like explicitly calling some kind of dispose method or something?

wujek-srujek avatar Feb 14 '21 16:02 wujek-srujek

+1 to this issue. I am also facing a similar issue and I think this will be a common use case across most of the app where we need to have one-time future providers Initialization before the app starts.

I want a shareprefernce and database one time.

final sharePrefProvider = FutureProvider((ref) => SharedPreferences.getInstance());

final hiveDatabaseInfo = FutureProvider((ref) => Hive.openBox('databaseInfo'));

final appDatabaseProvider = Provider<AppDatabase>((ref) {
  final prefsProvider = ref.watch(hiveDatabaseInfo);
  return AppDatabaseImpl(prefsProvider.data?.value);
});

final appSettingProvider = Provider.autoDispose((ProviderReference ref) {
  final prefsProvider = ref.watch(sharePrefProvider);
  return AppSettings(prefsProvider.data?.value);
});

Same as this comment I don't want to propagate that Future and AsyncValue to other providers and also to the UI code.

@TimWhiting Solutions to override provide works but it still looks like a hack and which needs to more explicitly document in the code.

Thinking out loud.....we can think of a provider that forces to convert FutureProvider to Provider without managing Future and AsyncValue in the second provider. The provider will wait until that FutureProvider is completed. Or a mechanism to await inside a Provider .

burhanrashid52 avatar Jun 13 '21 05:06 burhanrashid52

Thinking out loud.....we can think of a provider that forces to convert FutureProvider to Provider without managing Future and AsyncValue in the second provider.

Rather than converting FutureProvider to Provider, you may want to do the opposite and have:

final sharePrefProvider = FutureProvider((ref) => SharedPreferences.getInstance());

final hiveDatabaseInfo = FutureProvider((ref) => Hive.openBox('databaseInfo'));

final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
  final prefs = await ref.watch(hiveDatabaseInfo.future);
  return AppDatabaseImpl(prefs);
});

final appSettingProvider = FutureProvider.autoDispose((ref) async {
  final prefs = await ref.watch(sharePrefProvider.future);
  return AppSettings(prefs);
});

rrousselGit avatar Jul 12 '21 13:07 rrousselGit

Thinking out loud.....we can think of a provider that forces to convert FutureProvider to Provider without managing Future and AsyncValue in the second provider.

Rather than converting FutureProvider to Provider, you may want to do the opposite and have:

final sharePrefProvider = FutureProvider((ref) => SharedPreferences.getInstance());

final hiveDatabaseInfo = FutureProvider((ref) => Hive.openBox('databaseInfo'));

final appDatabaseProvider = FutureProvider<AppDatabase>((ref) async {
  final prefs = await ref.watch(hiveDatabaseInfo.future);
  return AppDatabaseImpl(prefs);
});

final appSettingProvider = FutureProvider.autoDispose((ref) async {
  final prefs = await ref.watch(sharePrefProvider.future);
  return AppSettings(prefs);
});

Yes, but everytime, you a re trying to get appDatabaseProvider or appSettingProvider..you will have to handle loading/error etc..?

Abacaxi-Nelson avatar Jul 12 '21 14:07 Abacaxi-Nelson

Agree with @Abacaxi-Nelson comment. This solution will force to handle loading/error every-time we access the appDatabaseProvider which is actually already loaded when app started.

burhanrashid52 avatar Jul 13 '21 09:07 burhanrashid52

If your futures are loaded before the app start, load them in the main rather than in a FutureProvider

rrousselGit avatar Jul 13 '21 10:07 rrousselGit

If your futures are loaded before the app start, load them in the main rather than in a FutureProvider

The problem comes when the async initialization takes a bit of time, and one wants to show a progress indicator (just once)

davidmartos96 avatar Jul 13 '21 10:07 davidmartos96

Then the initialization isn't done before the app start, so you need to deal with loading/error within the application

I don't understand the problem. You don't have to use when(data: ..., loading: ..., error: ...) everywhere

rrousselGit avatar Jul 13 '21 10:07 rrousselGit

Then the initialization isn't done before the app start, so you need to deal with loading/error within the application

I don't understand the problem. You don't have to use when(data: ..., loading: ..., error: ...) everywhere

You are saying do async task once and pass down the result to the tree ? We will not be able to use the context.read :(

Abacaxi-Nelson avatar Jul 13 '21 10:07 Abacaxi-Nelson

I'm saying that AsyncValue exposes a way to obtain the value without when

You can use asyncValue.data!.value if you don't want to deal with loading state in this place

rrousselGit avatar Jul 13 '21 10:07 rrousselGit

Would be nice if there were a built-in completer to use in the main() function then. Currently, my workaround is:

// in main()
container.read(prefsProvider); // trigger initialisation
await prefsInitCompleter.future; // wait for initialisation to complete

// in provider code
final prefsInitCompleter = Completer<void>();
final prefsProvider = FutureProvider<SharedPreferences>((ref) async {
  final prefs = await SharedPreferences.getInstance();
  prefsInitCompleter.complete();
  return prefs;
});

skytect avatar Jul 13 '21 11:07 skytect

Would be nice if there were a built-in completer to use in the main() function then. Currently, my workaround is:

This is built directly into FutureProvider:

final prefsProvider = FutureProvider<SharedPreferences>((ref) {
  return SharedPreferences.getInstance();
});

void main() async {
  final container = ProviderContainer();

  await container.read(prefsProvider.future);
  
  runApp(
    UncontrolledProviderScope(
      container: container,
      child: MyApp(),
    ),
  );
}

rrousselGit avatar Jul 13 '21 11:07 rrousselGit

asyncValue.data!.value

My first solution was this only. But then If any other provider depends on it than they also need to handle the null value or make the dependencies as nullable which is not I wanted to do. Ex

final appDatabaseProvider = Provider<AppDatabase>((ref) {
  final prefsProvider = ref.watch(hiveDatabaseInfo);
  return AppDatabaseImpl(prefsProvider.data!.value);
});

class AppDatabaseImpl{
   final Box? box // I want to avoid nullable dependencies
   AppDatabaseImpl(this.box)
}

burhanrashid52 avatar Jul 13 '21 12:07 burhanrashid52