Introducing Solidart: A Simplified Approach to Flutter State Management Inspired by SolidJS

Dan
5 min readJun 16, 2024

--

In this article I’m going to introduce you to Flutter Solidart, a state management package inspired, as the name suggests, by SolidJS.

This guide will cover a good part of the features Solidart provides, but if you’re looking for deeper insights, the full knowledge base can be found here.

You will find a little code samples in order to make things more clear.

Setup

Add the required dependency:

flutter pub add flutter_solidart: ^2.0.0-dev.1

While I’m writing, flutter_solidart ^2.x is in prerelease. If by the time you’re reading it has been released, just run the following:

flutter pub add flutter_solidart

Usages

Signal

A Signal is a class used to propagate a value of a given type to its listeners. Here’s a small example:

class _SignalExampleState extends State<SignalExample> {
// This count will call an update on its listeners every time it changes.
final counter = Signal(0);

@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: SignalBuilder(builder: (_, __) {
return Text('Value: ${counter.value}');
}),
),
IconButton(
onPressed: () => counter.value++,
icon: const Icon(Icons.add_rounded),
),
],
);
}
}

Notice that a SignalBuilder keeps track of all the signals referenced in the provided builder until the widget is returned. To make things clearer, take a look at the following example:

// This count will call an update on its listeners every time it changes.
final counter = Signal(0);

final shouldShowCounter = Signal(false);

@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (_, __) {
if (!shouldShowCounter()) return const SizedBox();
return Text('Value: ${counter.value}');
});
}

In this example, if shouldShowCounter.value is false then the subscription to counter is not registered. This means that in this specific scenario the changes to counter.value will NOT retrigger the builder function at all.

What if we want to perform a non-UI action depending on the value a given Signal assumes? The Signal.observe(previousValue, currentValue) comes handy in such cases:

final mySignal = Signal(0);
mySignal.observe((previous, current) {
// Perform actions based on these values
});

Note that a Signal can be manually disposed by calling the dispose() method. By default it behaves as specified globally in the static field SolidartConfig.autoDispose (which defaults to true). If you want specific Signal not to be auto disposable, just provide this option in its creation:

final mySignal = Signal(0, options: SignalOptions(autoDispose: false));

Effect

An Effect allows to register a function that is called anytime the values of the signals it is registered for change. The function provides a disposeEffect which can be used inside to dispose it and avoid further executions depending on your needs.

final mySignal = Signal(0);
final effect = Effect((disposeEffect) {
print('MySignal value: ${mySignal.value}');
// If we need to dispose this effect, just call disposeEffect()
});

Unlike signals/resources and solids, you’ll have to ALWAYS take care of the effect disposal manually in order to avoid memory leaks by calling dispose().

Computed

Computed is a Signal that offers a value that, as the name suggests, derives from a computation. Here’s an example of a computed that listens to the value of two signals in order to expose their sum.

final counterA = Signal(0);
final counterB = Signal(0);
final sumCounter = Computed(() {
return counterA.value + counterB.value;
});

Computed can expose a different type of data than the source. E.g. we can have a computed that tells if a given counter value is even or not (thus exposing a boolean and not an int).

Resource

A Resource is a class which can be used to handle the results coming from both futures and streams.

Here’s a basic example of the usage of a Resource:

class MyWidgetState extends State {
final resourceWithFuture = Resource(
fetcher: () async {
await Future.delayed(const Duration(seconds: 2));
return 'Some data from some network call';
}
);

@override
Widget build(BuildContext context) {
return SignalBuilder(
builder: (_, __) {
return resourceWithFuture.state.on(
ready: (data) => Text('Data: $data'),
error: (error, stackTrace) {
return Text('Error: $error, stack: $stack');
},
loading: () => const Text('Loading...'),
);
},
);
}
}

Invoke the resource or call resource.state in order to access to an istance of ResourceState<T>, a useful class that allow us to iterate through the outcomes with the map(…), on(…), maybeMap(…) and maybeOn(…) methods.

A Resource can be initialized not only with a future but also with a stream. The way of using a Resource initialized with a stream remains identical to the one initialized with a fetcher. Here’s an example:

final resourceWithStream = Resource(
stream: () => Stream.periodic(
const Duration(seconds: 2),
(i) => '$i'
),
);

A resource can be given a source parameter in order to react to its changes:

final userId = Signal('user_id');

final resourceWithFuture = Resource(
source: userId,
fetcher: () async {
// The fetchUserData function will be triggered anytime userId's
// value changes, thus updating the resource state.
return fetchUserData(userId.value);
},
);

ResourceState.isRefreshing tells if the resource is loading more updated data.

Solid

A Solid is a widget that serves as an Inherited Widget that supplies a list of providers to its subtree. It is useful when we want to propagate to the leaves a Signal which is way up in the tree and access it in a O(1) time.

Here’s a basic example of the usage of a Solid:

class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Solid(
providers: [
Provider<Signal<int>>(create: () => Signal(0), id: #counter),
],
builder: (context) {
final signal = context.get<Signal<int>>(#counter);
// ...
}
),
}
}

All the providers declared in the solid are accessible from the subtree using context.get<T>().

One thing to note is that the provider accepts an id, useful when we have multiple Providers of the same type up in the tree because in that scenario, context.get<T>() will return the first provider matching T.

If we want to access a specific Provider, we will need to use the id field and get the provider as follow: context.get<T>(<id>).

Another thing to point out here is that by observing a Signal supplied by a Solid by calling context.observe<Signal<T>>() while in the build method, we will have access to the signal value changes without the need of using a SignalBuilder.

Conclusions

I’ve used many State Management libraries and what I appreciate the most about Solidart is its simple interface (it is very quick to get used to Signals/Computed/Resources). It is well maintained and documented, therefore allowing the developer to get a deeper knowledge about ‘what lies below’ in an easy way.

I highly suggest this library because there are many features beyond the ones covered in this article, which you can find in the official documentation.

Hope you liked this article, more to come!

--

--

Dan

Mobile/Web Developer 📱 Writing about Flutter 💙