Solid Framework for Flutter

repository·main·Indexed 41 days ago

https://github.com/nank1ro/solid

A lightweight framework for Flutter that simplifies app development by automating boilerplate, state management, and dependency injection. Inspired by SolidJS, it implements a fine-grained reactivity system to update only specific UI elements. Key features include annotations like @SolidState, @SolidEffect, @SolidQuery, and @SolidEnvironment for reactive state and dependency injection.

Tokens
33K
Snippets
98
Records
135
Agent score
51%

What's inside Solid

  1. How Tracked vs Untracked Reads work

    main

    A read is tracked if the widget subtree containing it must rebuild when the signal changes. A read is untracked if it reads the current value but must NOT cause the enclosing widget subtree to subscribe.

    Untracked reads still have .value appended by the rewriter for type safety, but they do not trigger SignalBuilder wrapping of their parent widget subtree.

  2. How fine-grained reactivity works in Solid

    main

    Solid provides fine-grained reactivity inspired by SolidJS. Instead of rebuilding entire widget trees, Solid tracks which specific parts of the UI depend on which state variables. When a state variable changes, only the specific UI elements that depend on that state are updated.

    For example, if you have a Text widget displaying a counter and another Text widget displaying the current time, updating the counter will not trigger a rebuild of the time widget. This happens automatically without manual state management or dependency injection work.

    import 'package:flutter/material.dart';
    import 'package:solid_annotations/solid_annotations.dart';
    
    class Counter extends StatelessWidget {
      Counter({super.key});
    
      @SolidState()
      int counter = 0;
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
          body: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                Text('Date: ${DateTime.now()}'),
                Text('Counter is $counter'),
              ],
            ),
          ),
          floatingActionButton: FloatingActionButton(
            onPressed: () => counter++,
            child: const Icon(Icons.add),
          ),
        );
      }
    }
  3. How @SolidQuery handles auto-tracking and dependencies

    main

    Queries annotated with @SolidQuery are lazy and materialize upon their first invocation.

    Auto-tracking: If one @SolidQuery reads the value of another @SolidQuery within its body, Solid automatically establishes a reactive dependency. The downstream query will automatically refresh whenever the upstream query emits a new value.

    Safe Access: To avoid errors when an upstream query fails, use the asReady?.value pattern. This prevents the common mistake of calling .value on a query that is currently in an error state, which would otherwise rethrow the error.

    Opting out: If you need to read a query value without establishing a reactive dependency, use the .untracked extension: <queryName>().untracked.

  4. Detect and manage query refreshing states

    main

    When a query re-executes because a dependency changed, it defaults to a "refreshing" mode rather than entering the loading state. This prevents the UI from flickering or showing a full-screen loading indicator while new data is being fetched.

    Detecting a refresh

    Use the .isRefreshing property to check if a query is currently updating its data:

    fetchData().isRefreshing

    Changing refresh behavior

    If you want the query to enter the loading state again (showing the loading widget) every time it re-executes, set useRefreshing to false in the annotation:

    @SolidQuery(useRefreshing: false)
  5. The Cardinal Rule: Edit `source/`, never `lib/`

    main

    In a Solid project, the standard Flutter directory structure is inverted.

    Never edit files in lib/.

    In Solid, lib/ contains the generated output from build_runner. Every time you run the generator, it overwrites lib/ based on the contents of source/. Any manual changes made to lib/ will be silently lost on the next build.

    • Source of truth: source/<x>.dart
    • Generated output: lib/<x>.dart
    • Entry point: source/main.dart (not lib/main.dart)
  6. How Solid handles source and lib directories

    main

    Solid operates as a transpiler rather than a simple code generator. It takes code written in the source directory and transforms it into working code located in the lib directory. This is necessary because Flutter requires the app to be built from the lib folder.

    Note: The mapping from ^source/{{}}.dart to lib/{{}}.dart is hard-coded in the solid_generator builder configuration. Changing these directories requires forking the builder config in packages/solid_generator/build.yaml.

  7. Understand Reads vs Writes in Solid

    main

    In Solid, every reference to a reactive identifier is categorized as either a read or a write. Understanding this distinction is critical for managing reactivity and avoiding unnecessary rebuilds.

    • Writes: Any assignment form (=, compound assignments like +=, ++, --). Writes do not subscribe to the signal. The rewriter automatically appends .value to ensure typechecking, but writes never trigger SignalBuilder wrapping.
    • Reads: Observing a reactive value (e.g., Text(counter), if (counter > 0), print(counter)). Reads may or may not subscribe to the signal depending on the context (see Tracked vs Untracked Reads).
  8. Understand the Starlight project structure

    main

    Starlight projects follow a specific directory structure for content and assets:

    • Documentation Content: Place .md or .mdx files in src/content/docs/. Each file name determines its route.
    • Images: Store images in src/assets/ and reference them in Markdown using relative links.
    • Static Assets: Place files like favicons in the public/ directory.
    • Configuration: Project settings are managed via astro.config.mjs and content.config.ts.
    .
    ├── public/
    ├── src/
    │   ├── assets/
    │   ├── content/
    │   │   └── docs/
    │   └── content.config.ts
    ├── astro.config.mjs
    ├── package.json
    └── tsconfig.json
  9. How Solid's source and lib directories work

    main

    Solid uses an inverted directory structure compared to standard Flutter development:

    • source/ folder: This is your source of truth. This is where you write your authored code and annotations.
    • lib/ folder: This is the generated realm. Solid transpiles your code from source/ into an optimized version in lib/.

    Important: Never edit the lib/ folder manually, as it is overwritten by the generator. All imports within the same package should be relative to avoid resolving to the generated lib/ files instead of your authored files.

  10. How the generator handles file and directory pruning

    main

    The Solid generator ensures that the generated lib/ directory stays in sync with your source/ directory by performing automatic pruning. This prevents 'orphan' files (generated files that no longer have a source counterpart) from accumulating.

    Pruning Behavior:

    • File Deletion: If you delete a file in source/, the corresponding file in lib/ is automatically removed.
    • Directory Cleanup: If a directory in lib/ becomes empty because its contents were pruned, and that directory no longer has a corresponding directory in source/, the empty lib/ directory is also removed.
    • Structure Preservation: The generator will not remove a directory in lib/ if the corresponding directory still exists in source/ (even if the source/ directory is empty). This ensures your lib/ tree mirrors your source/ tree structure.
    • Safety: The pruning process is idempotent and runs at the start of the build process to ensure no transient orphan files are left behind during a build cycle.
  11. How @SolidEnvironment() works

    main

    The @SolidEnvironment() annotation is used to inject a value from the widget tree into a widget. It functions similarly to SwiftUI's @Environment property wrapper: it is type-keyed, and the lookup occurs lazily on the first access.

    Crucially, if the injected type contains @SolidState fields, those fields remain reactive within your build method. This allows for fine-grained reactivity where only the specific widgets reading the state rebuild, rather than the entire widget tree.

    class CounterDisplay extends StatelessWidget {
      CounterDisplay({super.key});
    
      @SolidEnvironment()
      late Counter counter;
    
      @override
      Widget build(BuildContext context) {
        return Center(child: Text('Counter is ${counter.value}'));
      }
    }
  12. Avoid using @SolidState on State<X> subclasses

    main

    The @SolidState annotation cannot be used on getters within a class that already extends State<X>. This is because the generator does not yet support adding @SolidState logic to an existing State subclass. If you need reactive state, define it in a separate controller or use a StatefulWidget pattern that doesn't involve annotating getters inside the State class itself.

    // This will cause a CodeGenerationError
    class _ProbeAWidgetState extends State<ProbeAWidget> {
      @SolidState() int counter = 0;
      @SolidState() int get probe => counter * 2; // Error: @SolidState getter on existing State<X> subclass is not yet supported
    }