Solidart

repository·main·Indexed 42 days ago

https://github.com/nank1ro/solidart

A lightweight state-management library for Dart and Flutter inspired by SolidJS. It provides fine-grained reactivity using Signals, Effects, and Computed values without requiring code generation. Includes support for asynchronous data via Resources, integration with flutter_hooks through solidart_hooks, and developer tools including a DevTools extension and solidart_lint.

Tokens
42.3K
Snippets
171
Records
207
Agent score
50%

What's inside Solidart

  1. Overview of solidart

    main

    What is solidart?

    solidart is an open-source state-management library designed for Dart and Flutter applications. It is inspired by the fine-grained reactivity model of SolidJS.

    Core Design Principles

    When using solidart, you can expect the following architectural characteristics:

    • Fine-grained Reactivity: Uses concepts like Signals, Effects, and Computeds (similar to SolidJS).
    • Decentralized State: Instead of a single global state tree, solidart encourages managing multiple states only in the most appropriate places.
    • No Code Generation: You can manage state without the overhead of build runners or code-gen steps.
    • Framework Alignment: Designed to fit well with standard Dart and Flutter best practices.
    • Scalability: Built to be performant and efficient for both small and large-scale applications.
    • Testability: The architecture is designed to be easily testable.
  2. What is a Resource and how does it work?

    main
    A Resource is a specialized Signal designed to manage asynchronous loading. It wraps async values (from Futures or Streams) to provide a unified way to handle common states: data, error, and loading. Because it extends Signal, all standard Signal APIs are available on a Resource instance.
  3. Understand ResourceReady and ResourceError states

    main

    ResourceReady<T>

    Indicates the resource is resolved and ready to use.

    • Constructor: ResourceReady(T value, {bool isRefreshing = false});
    • Methods:
      • copyWith({T? value, bool? isRefreshing}): Creates a new state with overridden values.

    ResourceError<T>

    Indicates the resource failed to load.

    • Constructor: ResourceError(Object error, {StackTrace? stackTrace, bool isRefreshing = false});
    • Methods:
      • copyWith({Object? error, StackTrace? stackTrace, bool? isRefreshing}): Creates a new state with overridden values.
  4. How to optimize rebuilds with SignalBuilder vs useListenable

    main

    When working with signals in Flutter hooks, you have two primary ways to trigger rebuilds:

    1. SignalBuilder (Recommended): Wrap only the specific widgets that need to react to a signal change. This is highly efficient as it avoids rebuilding the entire widget tree.
    2. useListenable (from flutter_hooks): Passing a signal to useListenable(signal) will cause the entire HookWidget to rebuild whenever the signal changes. Use this only when the entire widget's state is dependent on the signal.
    // Rebuilds the entire widget
    final count = useSignal(0);
    useListenable(count);
    
    // Rebuilds only the content inside the builder
    final count = useSignal(0);
    SignalBuilder(
      builder: (context, child) => Text('${count.value}'),
    )
  5. How Resources react to source changes

    main

    A powerful feature of Resource is its ability to switch async sources at runtime. By providing a source (which can be a Signal or a Computed), the Resource will automatically re-trigger its fetcher whenever the source changes.

    If the source depends on multiple signals, use a Computed to group them into a single source.

    final userId = Signal<String?>(null);
    final authToken = Signal<String?>(null);
    
    // Use a Computed to react to multiple signal changes
    final source = Computed(() => (userId.value, authToken.value));
    
    late final userData = Resource(
      () {
        return switch (userId.value) {
          null => Future.value(null),
          final id => getUserData(id),
        };
      },
      source: source,
    );
  6. Create derived state with Computed

    main

    A Computed signal is a read-only signal that derives its value from other signals. It automatically subscribes to any signals accessed within its constructor function and reruns that function whenever those dependencies change.

    Key characteristics:

    • Automatic Dependency Tracking: You don't need to manually manage subscriptions; Computed tracks which signals are read during execution.
    • Read-Only: Computed implements the ReadSignal interface, meaning it does not have a setter (you cannot call .set() or .updateValue() on it).
    • Type Transformation: You can use Computed to transform data types (e.g., from an int to a bool).
    final name = Signal('John');
    final lastName = Signal('Doe');
    final fullName = Computed(() => '${name.value} ${lastName.value}');
    print(fullName()); // prints "John Doe"
    
    // Update the name
    name.set('Jane');
    print(fullName()); // prints "Jane Doe"
  7. How Effect dependency tracking works

    main

    An Effect only tracks the current dependencies accessed during its most recent execution. This means if your effect contains conditional logic, the set of signals it tracks can change dynamically.

    • If a signal is accessed inside an if block that evaluates to false, that signal is not tracked.
    • If the condition changes and the signal is subsequently accessed, the effect will then start tracking that signal.

    Warning: Use conditional logic carefully, as it can lead to unexpected behavior where an effect does not react to changes in signals that were not part of its last execution path.

    final name = Signal("John");
    final lastName = Signal("Doe");
    final showFullName = Signal(false);
    
    Effect((_) {
      if (showFullName()) {
        print("${name.value} ${lastName.value}");
      } else {
        print(name.value);
      }
    });
    
    // 1. Initially prints "John" and tracks [name, showFullName].
    // 2. Changing lastName to "Smith" triggers NOTHING because lastName is not currently tracked.
    // 3. Setting showFullName to true triggers the effect. 
    // 4. It now prints "John Doe" and tracks [name, lastName, showFullName].
  8. How Computed signals work

    main

    A Computed is a read-only signal that depends on other signals. It automatically subscribes to any signal accessed within its constructor function and reruns that function whenever any of its dependencies change.

    Key characteristics:

    • Automatic Dependency Tracking: It tracks which signals are accessed during execution.
    • Read-Only: A Computed returns a ReadSignal, which means it does not have a value setter. You cannot manually update its value; it only changes when its dependencies change.
    • Type Transformation: You can use Computed to transform the type of a signal (e.g., transforming an int signal into a bool signal based on a condition).
    • Value Stability: If the result of the computation remains the same after a dependency change (e.g., a boolean condition that stays false), the Computed signal will not emit a new value to its own subscribers.
    final counter = Signal(0); // type: int
    final isGreaterThan5 = Computed(() => counter() > 5); // type: bool
  9. How the Solid widget and Providers work together

    main

    The Solid widget manages a collection of Provider objects.

    Key Concepts:

    • Lazy Creation: By default, providers are created lazily via the create function. The signal is only instantiated when it is first accessed. To force immediate creation, set lazy: false.
    • Identifiers (id): You can provide multiple providers of the same type by assigning a unique id to each. This allows you to distinguish between them when retrieving them from the context.
    • Lifecycle (dispose): You can provide a dispose callback to the Provider. This function is triggered when the Solid widget is removed from the widget tree, allowing for clean-up logic (e.g., closing streams or clearing resources).
    • Type Safety: It is mandatory to specify the explicit type of the signal in the Provider (e.g., Provider<Signal<T>>) and when calling observe or get to avoid errors.
  10. Understand automatic disposal in solidart

    main

    By default, solidart automatically disposes of Signal, Computed, Resource, and Effect objects when they are no longer needed.

    • Signals, Computeds, and Resources: Disposed automatically when there are no longer any subscribers or listeners.
    • Effects: Disposed automatically when all currently tracked dependencies are disposed.
    • Observations: Follow the same automatic disposal behavior as Effects.

    Important: You must always manually dispose of Effect and Observation instances to prevent memory leaks, as they can create circular dependency chains that prevent automatic disposal.

  11. Control signal updates with equals and comparator

    main

    You can prevent a signal from triggering updates if the new value is considered equal to the current value. There are two ways to define equality:

    1. Using equals: Set equals to true to use the standard == equality operator. When equals is true, the comparator is ignored.
    2. Using comparator: If equals is false (the default), you can provide a custom ValueComparator<T?> function. The signal will prevent an update if the comparator returns true. By default, this uses identical.

    Use these options to optimize performance by avoiding redundant re-renders or computations when data hasn't effectively changed.

  12. What are Resources and how do they work

    main

    In solidart, Resources are specialized Signals designed to manage asynchronous loading states. They wrap async values to provide a standardized way to handle the three common states of a future: data (ready), error, and loading.

    Resources can be driven in two ways:

    1. By a source signal: A source signal provides a query/parameter to an async fetcher function. When the source changes, the resource automatically re-fetches.
    2. By a stream: Instead of a fetcher function, you can provide a stream to the Resource class to drive it from reactive stream data.

    Resources are agnostic to the data fetching mechanism (REST, GraphQL, etc.) as long as the mechanism returns a Future or a Stream.

    // Example: Resource driven by a source signal
    final userId = Signal(1);
    
    Future<String> fetchUser() async {
        // ... fetching logic using userId.value
    }
    
    final user = Resource(fetchUser, source: userId);