Effective Dart Rules¶
Naming Conventions¶
- Use terms consistently throughout your code.
- Follow existing mnemonic conventions when naming type parameters (e.g.,
Efor element,K/Vfor key/value,T/S/Ufor generic types). - Name types using
UpperCamelCase(classes, enums, typedefs, type parameters). - Name extensions using
UpperCamelCase. - Name packages, directories, and source files using
lowercase_with_underscores. - Name import prefixes using
lowercase_with_underscores. - Name other identifiers using
lowerCamelCase(variables, parameters, named parameters). - Capitalize acronyms and abbreviations longer than two letters like words.
- Avoid abbreviations unless the abbreviation is more common than the unabbreviated term.
- Prefer putting the most descriptive noun last in names.
- Consider making code read like a sentence when designing APIs.
- Prefer a noun phrase for non-boolean properties or variables.
- Prefer a non-imperative verb phrase for boolean properties or variables.
- Prefer the positive form for boolean property and variable names.
- Consider omitting the verb for named boolean parameters.
- Use camelCase for variable and function names.
- Use PascalCase for class names.
- Use snake_case for file names.
Types and Functions¶
- Use class modifiers to control if your class can be extended or used as an interface.
- Type annotate variables without initializers.
- Type annotate fields and top-level variables if the type isn't obvious.
- Annotate return types on function declarations.
- Annotate parameter types on function declarations.
- Write type arguments on generic invocations that aren't inferred.
- Annotate with
dynamicinstead of letting inference fail. - Use
Future<void>as the return type of asynchronous members that do not produce values. - Use getters for operations that conceptually access properties.
- Use setters for operations that conceptually change properties.
- Use a function declaration to bind a function to a name.
- Use inclusive start and exclusive end parameters to accept a range.
Style¶
- Format your code using
dart format. - Use curly braces for all flow control statements.
- Prefer
finalovervarwhen variable values won't change. - Use
constfor compile-time constants.
Imports & Files¶
- Don't import libraries inside the
srcdirectory of another package. - Don't allow import paths to reach into or out of
lib. - Prefer relative import paths within a package.
- Don't use
/lib/or../in import paths. - Consider writing a library-level doc comment for library files.
Structure¶
- Keep files focused on a single responsibility.
- Limit file length to maintain readability.
- Group related functionality together.
- Prefer making fields and top-level variables
final. - Consider making your constructor
constif the class supports it. - Prefer making declarations private.
- Always use barrel files.
Barrel files are files that re-export a collection of other files, making it easier to import related functionality. For example, instead of importing multiple files individually, you can create awidgets.dartfile that exports all widget files in a directory:Then, you can import all widgets with a single statement:// widgets.dart export 'button.dart'; export 'card.dart'; export 'form.dart';import 'widgets/widgets.dart';
Usage¶
- Use strings in
part ofdirectives. - Use adjacent strings to concatenate string literals.
- Use collection literals when possible.
- Use
whereType()to filter a collection by type. - Test for
Future<T>when disambiguating aFutureOr<T>whose type argument could beObject. - Follow a consistent rule for
varandfinalon local variables. - Initialize fields at their declaration when possible.
- Use initializing formals when possible.
- Use
;instead of{}for empty constructor bodies. - Use
rethrowto rethrow a caught exception. - Override
hashCodeif you override==. - Make your
==operator obey the mathematical rules of equality.
Documentation¶
- Format comments like sentences.
- Use
///doc comments to document members and types; don't use block comments for documentation. - Prefer writing doc comments for public APIs.
- Consider writing doc comments for private APIs.
- Consider including explanations of terminology, links, and references in library-level docs.
- Start doc comments with a single-sentence summary.
- Separate the first sentence of a doc comment into its own paragraph.
- Use square brackets in doc comments to refer to in-scope identifiers.
- Use prose to explain parameters, return values, and exceptions.
- Put doc comments before metadata annotations.
- Document why code exists or how it should be used, not just what it does.
Testing¶
- Write unit tests for business logic.
- Write widget tests for UI components.
- Aim for good test coverage.
Widgets¶
- Extract reusable widgets into separate components.
- Use
StatelessWidgetwhen possible. - Keep build methods simple and focused.
State Management¶
- Choose appropriate state management based on complexity.
- Avoid unnecessary
StatefulWidgets. - Keep state as local as possible.
Performance¶
- Use
constconstructors when possible. - Avoid expensive operations in build methods.
- Implement pagination for large lists.
Architecture¶
- Separate your features into a UI Layer (presentation), a Data Layer (business data and logic), and, for complex apps, consider adding a Domain (Logic) Layer between UI and Data layers to encapsulate business logic and use-cases.
- You can organize code by feature: The classes needed for each feature are grouped together.
The folder structure should reflect this separation. For example:
features/ ├── auth/ │ ├── data/ │ │ ├── datasources/ │ │ │ ├── auth_local_data_source_impl.dart │ │ │ ├── auth_remote_data_source_impl.dart │ │ │ └── datasources.dart │ │ ├── repositories/ │ │ │ ├── auth_repository_impl.dart │ │ │ └── repositories.dart │ ├── domain/ │ │ ├── datasources/ │ │ │ ├── auth_data_source.dart │ │ │ └── datasources.dart │ │ ├── models/ │ │ │ ├── user_model.dart │ │ │ └── models.dart │ ├── presentation/ │ │ ├── providers/ │ │ │ ├── user_provider.dart │ │ │ └── providers.dart │ │ ├── screens/ │ │ │ ├── login_screen.dart │ │ │ └── screens.dart │ │ ├── widgets/ │ │ │ ├── login_form.dart │ │ │ └── widgets.dart │ └── auth.dart - Only allow communication between adjacent layers; the UI layer should not access the data layer directly, and vice versa.
- Clearly define the responsibilities, boundaries, and interfaces of each layer and component (Screens, Providers, Repositories, Datasources).
- Further divide each layer into components with specific responsibilities and well-defined interfaces.
- In the UI Layer, use Screens to describe how to present data to the user; keep logic minimal and only UI-related.
- Pass events from Screens to Providers in response to user interactions.
- In Providers, contain logic to convert app data into UI state and maintain the current state needed by the view.
- Expose callbacks (commands) from Providers to Screens and retrieve/transform data from repositories.
- In the Data Layer, use Repositories as the single source of truth (SSOT) for model data and to handle business logic such as caching, error handling, and refreshing data.
- Only the SSOT class (usually the repository) should be able to mutate its data; all other classes should read from it.
- Repositories should transform raw data from Datasources into domain models and output data consumed by Providers.
- Use Datasources to wrap API endpoints and expose asynchronous response objects; Datasources should isolate data-loading and hold no state.
- Use dependency injection to provide components with their dependencies, enabling testability and flexibility.
Data Flow and State¶
- Follow unidirectional data flow: state flows from the data layer through the logic layer to the UI layer, and events from user interaction flow in the opposite direction.
- Data changes should always happen in the SSOT (data layer), not in the UI or logic layers.
- The UI should always reflect the current (immutable) state; trigger UI rebuilds only in response to state changes.
- Screens should contain as little logic as possible and be driven by state from Providers.
Best Practices¶
- Strongly recommend following separation of concerns and layered architecture.
- Strongly recommend using dependency injection for testability and flexibility.
- Recommend using MVVM as the default pattern, but adapt as needed for your app's complexity.
- Use key-value storage for simple data (e.g., configuration, preferences) and SQL storage for complex relationships.
- Use optimistic updates to improve perceived responsiveness by updating the UI before operations complete.
- Support offline-first strategies by combining local and remote data sources in repositories and enabling synchronization as appropriate.
- Keep views focused on presentation and extract reusable widgets into separate components.
- Use
StatelessWidgetwhen possible and avoid unnecessaryStatefulWidgets. - Keep build methods simple and focused on rendering.
- Choose state management approaches appropriate to the complexity of your app.
- Keep state as local as possible to minimize rebuilds and complexity.
- Use
constconstructors when possible to improve performance. - Avoid expensive operations in build methods and implement pagination for large lists.
- Keep files focused on a single responsibility and limit file length for readability.
- Group related functionality together and use
finalfor fields and top-level variables when possible. - Prefer making declarations private and consider making constructors
constif the class supports it. - Follow Dart naming conventions and format code using
dart format. - Use curly braces for all flow control statements to ensure clarity and prevent bugs.
Dart 3 Updates¶
Branches¶
- Use
ifstatements for conditional branching. The condition must evaluate to a boolean. ifstatements support optionalelseandelse ifclauses for multiple branches.- Use
if-casestatements to match and destructure a value against a single pattern. Example:if (pair case [int x, int y]) { ... } - If the pattern in an
if-casematches, variables defined in the pattern are in scope for that branch. - If the pattern does not match in an
if-case, control flows to theelsebranch if present. - Use
switchstatements to match a value against multiple patterns (cases). Eachcasecan use any kind of pattern. - When a value matches a
casepattern in aswitchstatement, the case body executes and control jumps to the end of the switch.breakis not required. - You can end a non-empty
caseclause withcontinue,throw, orreturn. - Use
defaultor_in aswitchstatement to handle unmatched values. - Empty
caseclauses fall through to the next case. Usebreakto prevent fallthrough. - Use
continuewith a label for non-sequential fallthrough between cases. - Use logical-or patterns (e.g.,
case a || b) to share a body or guard between cases. - Use
switchexpressions to produce a value based on matching cases. Syntax differs from statements: omitcase, use=>for bodies, and separate cases with commas. - In
switchexpressions, the default case must use_(notdefault). - Dart checks for exhaustiveness in
switchstatements and expressions, reporting a compile-time error if not all possible values are handled. - To ensure exhaustiveness, use a default (
defaultor_) case, or switch over enums or sealed types. - Use the
sealedmodifier on a class to enable exhaustiveness checking when switching over its subtypes. - Add a guard clause to a
caseusingwhento further constrain when a case matches. Example:case pattern when condition: - Guard clauses can be used in
if-case,switchstatements, andswitchexpressions. The guard is evaluated after pattern matching. - If a guard clause evaluates to false, execution proceeds to the next case (does not exit the switch).
Patterns¶
- Patterns are a syntactic category that represent the shape of values for matching and destructuring.
- Pattern matching checks if a value has a certain shape, constant, equality, or type.
- Pattern destructuring allows extracting parts of a matched value and binding them to variables.
- Patterns can be nested, using subpatterns (outer/inner patterns) for recursive matching and destructuring.
- Use wildcard patterns (
_) to ignore parts of a matched value; use rest elements in list patterns to ignore remaining elements. - Patterns can be used in:
- Local variable declarations and assignments
- For and for-in loops
- If-case and switch-case statements
- Control flow in collection literals
- Pattern variable declarations start with
varorfinaland bind new variables from the matched value. Example:var (a, [b, c]) = ('str', [1, 2]); - Pattern variable assignments destructure a value and assign to existing variables. Example:
(b, a) = (a, b); // swap values - Every case clause in
switchandif-casecontains a pattern. Any kind of pattern can be used in a case. - Case patterns are refutable; if the pattern doesn't match, execution continues to the next case.
- Destructured values in a case become local variables scoped to the case body.
- Use logical-or patterns (e.g.,
case a || b) to match multiple alternatives in a single case. - Use logical-or patterns with guards (
when) to share a body or guard between cases. - Guard clauses (
when) evaluate a condition after matching; if false, execution proceeds to the next case. - Patterns can be used in for and for-in loops to destructure collection elements (e.g., destructuring
MapEntryin map iteration). - Object patterns match named object types and destructure their data using getters. Example:
var Foo(:one, :two) = myFoo; - Use patterns to destructure records, including positional and named fields, directly into local variables.
- Patterns enable algebraic data type style code: use sealed classes and switch on subtypes for exhaustive matching.
- Patterns simplify validation and destructuring of complex data structures, such as JSON, in a declarative way. Example:
if (data case {'user': [String name, int age]}) { ... } - Patterns provide a concise alternative to verbose type-checking and destructuring code.
Pattern Types¶
- Pattern precedence determines evaluation order; use parentheses to group lower-precedence patterns.
- Logical-or patterns (
pattern1 || pattern2) match if any branch matches, evaluated left-to-right. All branches must bind the same set of variables. - Logical-and patterns (
pattern1 && pattern2) match if both subpatterns match. Bound variable names must not overlap between subpatterns. - Relational patterns (
==,!=,<,>,<=,>=) match if the value compares as specified to a constant. Useful for numeric ranges and can be combined with logical-and. - Cast patterns (
subpattern as Type) assert and cast a value to a type before passing it to a subpattern. Throws if the value is not of the type. - Null-check patterns (
subpattern?) match if the value is not null, then match the inner pattern. Binds the non-nullable type. Use constant patternnullto match null. - Null-assert patterns (
subpattern!) match if the value is not null, else throw. Use in variable declarations to eliminate nulls. Use constant patternnullto match null. - Constant patterns match if the value is equal to a constant (number, string, bool, named constant, const constructor, const collection, etc.). Use parentheses and
constfor complex expressions. - Variable patterns (
var name,final Type name) bind new variables to matched/destructured values. Typed variable patterns only match if the value has the declared type. - Identifier patterns (
foo,_) act as variable or constant patterns depending on context._always acts as a wildcard and matches/discards any value. - Parenthesized patterns (
(subpattern)) control pattern precedence and grouping, similar to expressions. - List patterns (
[subpattern1, subpattern2]) match lists and destructure elements by position. The pattern length must match the list unless a rest element is used. - Rest elements (
...,...rest) in list patterns match arbitrary-length lists or collect unmatched elements into a new list. - Map patterns (
{"key": subpattern}) match maps and destructure by key. Only specified keys are matched; missing keys throw aStateError. - Record patterns (
(subpattern1, subpattern2),(x: subpattern1, y: subpattern2)) match records by shape and destructure positional/named fields. Field names can be omitted if inferred from variable or identifier patterns. - Object patterns (
ClassName(field1: subpattern1, field2: subpattern2)) match objects by type and destructure using getters. Extra fields in the object are ignored. - Wildcard patterns (
_,Type _) match any value without binding. Useful for ignoring values or type-checking without binding. - All pattern types can be nested and combined for expressive and precise matching and destructuring.
Records¶
- Records are anonymous, immutable, aggregate types that bundle multiple objects into a single value.
- Records are fixed-sized, heterogeneous, and strongly typed. Each field can have a different type.
- Records are real values: store them in variables, nest them, pass to/from functions, and use in lists, maps, and sets.
- Record expressions use parentheses with comma-delimited positional and/or named fields, e.g.
('first', a: 2, b: true, 'last'). - Record type annotations use parentheses with comma-delimited types. Named fields use curly braces:
({int a, bool b}). - The names of named fields are part of the record's type (shape). Records with different named field names have different types.
- Positional field names in type annotations are for documentation only and do not affect the record's type.
- Record fields are accessed via built-in getters: positional fields as
$1,$2, etc., and named fields by their name (e.g.,.a). - Records are immutable: fields do not have setters.
- Records are structurally typed: the set, types, and names of fields define the record's type (shape).
- Two records are equal if they have the same shape and all corresponding field values are equal. Named field order does not affect equality.
- Records automatically define
hashCodeand==based on structure and field values. - Use records for functions that return multiple values; destructure with pattern matching:
var (name, age) = userInfo(json); - Destructure named fields with the colon syntax:
final (:name, :age) = userInfo(json); - Using records for multiple returns is more concise and type-safe than using classes, lists, or maps.
- Use lists of records for simple data tuples with the same shape.
- Use type aliases (
typedef) for record types to improve readability and maintainability. - Changing a record type alias does not guarantee all code using it is still type-safe; only classes provide full abstraction/encapsulation.
- Extension types can wrap records but do not provide full abstraction or protection.
- Records are best for simple, immutable data aggregation; use classes for abstraction, encapsulation, and behavior.
Riverpod Rules¶
Using Ref in Riverpod¶
- Installation
flutter pub add flutter_riverpod flutter pub add riverpod_annotation flutter pub add dev:riverpod_generator flutter pub add dev:build_runner flutter pub add dev:custom_lint flutter pub add dev:riverpod_lint - The
Refobject is essential for accessing the provider system, reading or watching other providers, managing lifecycles, and handling dependencies in Riverpod. - In functional providers, obtain
Refas a parameter; in class-based providers, access it as a property of the Notifier. - In widgets, use
WidgetRef(a subtype ofRef) to interact with providers. - The
@riverpodannotation is used to define providers with code generation, where the function receivesrefas its parameter. - Use
ref.watchto reactively listen to other providers; useref.readfor one-time access (non-reactive); useref.listenfor imperative subscriptions; useref.onDisposeto clean up resources. - Example: Functional provider with Ref
dart final otherProvider = Provider<int>((ref) => 0); final provider = Provider<int>((ref) { final value = ref.watch(otherProvider); return value * 2; }); - Example: Provider with @riverpod annotation
dart @riverpod int example(Ref ref) { return 0; } - Example: Using Ref for cleanup
dart final provider = StreamProvider<int>((Ref ref) { final controller = StreamController<int>(); ref.onDispose(controller.close); return controller.stream; }); - Example: Using WidgetRef in a widget
dart class MyWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final value = ref.watch(myProvider); return Text('$value'); } }
Combining Requests¶
- Use the
Refobject to combine providers and requests; all providers have access to aRef. - In functional providers, obtain
Refas a parameter; in class-based providers, access it as a property of the Notifier. - Prefer using
ref.watchto combine requests, as it enables reactive and declarative logic that automatically recomputes when dependencies change. - When using
ref.watchwith asynchronous providers, use.futureto await the value if you need the resolved result, otherwise you will receive anAsyncValue. - Avoid calling
ref.watchinside imperative code (e.g., listener callbacks or Notifier methods); only use it during the build phase of the provider. - Use
ref.listenas an alternative toref.watchfor imperative subscriptions, but preferref.watchfor most cases asref.listenis more error-prone. - It is safe to use
ref.listenduring the build phase; listeners are automatically cleaned up when the provider is recomputed. - Use the return value of
ref.listento manually remove listeners when needed. - Use
ref.readonly when you cannot useref.watch, such as inside Notifier methods;ref.readdoes not listen to provider changes. - Be cautious with
ref.read, as providers not being listened to may destroy their state if not actively watched.
Auto Dispose & State Disposal¶
- By default, with code generation, provider state is destroyed when the provider stops being listened to for a full frame.
- Opt out of automatic disposal by setting
keepAlive: true(codegen) or usingref.keepAlive()(manual). - When not using code generation, state is not destroyed by default; enable
.autoDisposeon providers to activate automatic disposal. - Always enable automatic disposal for providers that receive parameters to prevent memory leaks from unused parameter combinations.
- State is always destroyed when a provider is recomputed, regardless of auto dispose settings.
- Use
ref.onDisposeto register cleanup logic that runs when provider state is destroyed; do not trigger side effects or modify providers insideonDispose. - Use
ref.onCancelto react when the last listener is removed, andref.onResumewhen a new listener is added after cancellation. - Call
ref.onDisposemultiple times if needed—once per disposable object—to ensure all resources are cleaned up. - Use
ref.invalidateto manually force the destruction of a provider's state; if the provider is still listened to, a new state will be created. - Use
ref.invalidateSelfinside a provider to force its own destruction and immediate recreation. - When invalidating parameterized providers, you can invalidate a specific parameter or all parameter combinations.
- Use
ref.keepAlivefor fine-tuned control over state disposal; revert to automatic disposal using the return value ofref.keepAlive. - To keep provider state alive for a specific duration, combine a
Timerwithref.keepAliveand dispose after the timer completes. - Consider using
ref.onCancelandref.onResumeto implement custom disposal strategies, such as delayed disposal after a provider is no longer listened to.
Eager Initialization¶
- Providers are initialized lazily by default; they are only created when first used.
- There is no built-in way to mark a provider for eager initialization due to Dart's tree shaking.
- To eagerly initialize a provider, explicitly read or watch it at the root of your application (e.g., in a
Consumerplaced directly underProviderScope). - Place the eager initialization logic in a public widget (such as
MyApp) rather than inmain()to ensure consistent test behavior. - Eagerly initializing a provider in a dedicated widget will not cause your entire app to rebuild when the provider changes; only the initialization widget will rebuild.
- Handle loading and error states for eagerly initialized providers as you would in any
Consumer, e.g., by returning a loading indicator or error widget. - Use
AsyncValue.requireValuein widgets to read the data directly and throw a clear exception if the value is not ready, instead of handling loading/error states everywhere. - Avoid creating multiple providers or using overrides solely to hide loading/error states; this adds unnecessary complexity and is discouraged.
First Provider & Network Requests¶
- Always wrap your app with
ProviderScopeat the root (directly inrunApp) to enable Riverpod for the entire application. - Place business logic such as network requests inside providers; use
Provider,FutureProvider, orStreamProviderdepending on the return type. - Providers are lazy—network requests or logic inside a provider are only executed when the provider is first read.
- Define provider variables as
finaland at the top level (global scope). - Use code generators like Freezed or json_serializable for models and JSON parsing to reduce boilerplate.
- Use
ConsumerorConsumerWidgetin your UI to access providers via arefobject. - Handle loading and error states in the UI by using the
AsyncValueAPI returned byFutureProviderandStreamProvider. - Multiple widgets can listen to the same provider; the provider will only execute once and cache the result.
- Use
ConsumerWidgetorConsumerStatefulWidgetto reduce code indentation and improve readability over using aConsumerwidget inside a regular widget. - To use both hooks and providers in the same widget, use
HookConsumerWidgetorStatefulHookConsumerWidgetfromflutter_hooksandhooks_riverpod. - Always install and use
riverpod_lintto enable IDE refactoring and enforce best practices. - Do not put
ProviderScopeinsideMyApp; it must be the top-level widget passed torunApp. - When handling network requests, always render loading and error states gracefully in the UI.
- Do not re-execute network requests on widget rebuilds; Riverpod ensures the provider is only executed once unless explicitly invalidated.
Passing Arguments to Providers¶
- Use provider "families" to pass arguments to providers; add
.familyafter the provider type and specify the argument type. - When using code generation, add parameters directly to the annotated function (excluding
ref). - Always enable
autoDisposefor providers that receive parameters to avoid memory leaks. - When consuming a provider that takes arguments, call it as a function with the desired parameters (e.g.,
ref.watch(myProvider(param))). - You can listen to the same provider with different arguments simultaneously; each argument combination is cached separately.
- The equality (
==) of provider parameters determines caching—ensure parameters have consistent and correct equality semantics. - Avoid passing objects that do not override
==(such as plainListorMap) as provider parameters; useconstcollections, custom classes with proper equality, or Dart 3 records. - Use the
provider_parameterslint rule fromriverpod_lintto catch mistakes with parameter equality. - For multiple parameters, prefer code generation or Dart 3 records, as records naturally override
==and are convenient for grouping arguments. - If two widgets consume the same provider with the same parameters, only one computation/network request is made; with different parameters, each is cached separately.
FAQ & Best Practices¶
- Use
ref.refresh(provider)when you want to both invalidate a provider and immediately read its new value; useref.invalidate(provider)if you only want to invalidate without reading the value. - Always use the return value of
ref.refresh; ignoring it will trigger a lint warning. - If a provider is invalidated while not being listened to, it will not update until it is listened to again.
- Do not try to share logic between
RefandWidgetRef; move shared logic into aNotifierand call methods on the notifier viaref.read(yourNotifierProvider.notifier).yourMethod(). - Prefer
Reffor business logic and avoid relying onWidgetRef, which ties logic to the UI layer. - Extend
ConsumerWidgetinstead of using rawStatelessWidgetwhen you need access to providers in the widget tree, due to limitations ofInheritedWidget. InheritedWidgetcannot implement a reliable "on change" listener or track when widgets stop listening, which is required for Riverpod's advanced features.- Do not expect to reset all providers at once; instead, make providers that should reset depend on a "user" or "session" provider and reset that dependency.
hooks_riverpodandflutter_hooksare versioned independently; always add both as dependencies if using hooks.- Riverpod uses
identicalinstead of==to filter updates for performance reasons, especially with code-generated models; overrideupdateShouldNotifyon Notifiers to change this behavior. - If you encounter "Cannot use
refafter the widget was disposed", ensure you checkcontext.mountedbefore usingrefafter anawaitin an async callback.
Provider Observers (Logging & Error Reporting)¶
- Use a
ProviderObserverto listen to all events in the provider tree for logging, analytics, or error reporting. - Extend the
ProviderObserverclass and override its methods to respond to provider lifecycle events: didAddProvider: called when a provider is added to the tree.didUpdateProvider: called when a provider is updated.didDisposeProvider: called when a provider is disposed.providerDidFail: called when a synchronous provider throws an error.- Register your observer(s) by passing them to the
observersparameter ofProviderScope(for Flutter apps) orProviderContainer(for pure Dart). - You can register multiple observers if needed by providing a list to the
observersparameter. - Use observers to integrate with remote error reporting services, log provider state changes, or trigger custom analytics.
Performing Side Effects¶
- Use Notifiers (
Notifier,AsyncNotifier, etc.) to expose methods for performing side effects (e.g., POST, PUT, DELETE) and modifying provider state. - Always define provider variables as
finaland at the top level (global scope). - Choose the provider type (
NotifierProvider,AsyncNotifierProvider, etc.) based on the return type of your logic. - Use provider modifiers like
autoDisposeandfamilyas needed for cache management and parameterization. - Expose public methods on Notifiers for UI to trigger state changes or side effects.
- In UI event handlers (e.g., button
onPressed), useref.readto call Notifier methods; avoid usingref.watchfor imperative actions. - After performing a side effect, update the UI state by:
- Setting the new state directly if the server returns the updated data.
- Calling
ref.invalidateSelf()to refresh the provider and re-fetch data. - Manually updating the local cache if the server does not return the new state.
- When updating the local cache, prefer immutable state, but mutable state is possible if necessary.
- Always handle loading and error states in the UI when performing side effects.
- Use progress indicators and error messages to provide feedback for pending or failed operations.
- Be aware of the pros and cons of each update approach:
- Direct state update: most up-to-date but depends on server implementation.
- Invalidate and refetch: always consistent with server, but may incur extra network requests.
- Manual cache update: efficient, but risks state divergence from server.
- Use hooks (
flutter_hooks) orStatefulWidgetto manage local state (e.g., pending futures) for showing spinners or error UI during side effects. - Do not perform side effects directly inside provider constructors or build methods; expose them via Notifier methods and invoke from the UI layer.
Testing Providers¶
- Always create a new
ProviderContainer(unit tests) orProviderScope(widget tests) for each test to avoid shared state between tests. Use a utility likecreateContainer()to set up and automatically dispose containers (see/references/riverpod/testing/create_container.dart). - In unit tests, never share
ProviderContainerinstances between tests. Example:dart final container = createContainer(); expect(container.read(provider), equals('some value')); - In widget tests, always wrap your widget tree with
ProviderScopewhen usingtester.pumpWidget. Example:dart await tester.pumpWidget( const ProviderScope(child: YourWidgetYouWantToTest()), ); - Obtain a
ProviderContainerin widget tests usingProviderScope.containerOf(BuildContext). Example:dart final element = tester.element(find.byType(YourWidgetYouWantToTest)); final container = ProviderScope.containerOf(element); - After obtaining the container, you can read or interact with providers as needed for assertions. Example:
dart expect(container.read(provider), 'some value'); - For providers with
autoDispose, prefercontainer.listenovercontainer.readto prevent the provider's state from being disposed during the test. - Use
container.readto read provider values andcontainer.listento listen to provider changes in tests. - Use the
overridesparameter onProviderScopeorProviderContainerto inject mocks or fakes for providers in your tests. - Use
container.listento spy on changes in a provider for assertions or to combine with mocking libraries. - Await asynchronous providers in tests by reading the
.futureproperty (forFutureProvider) or listening to streams. - Prefer mocking dependencies (such as repositories) used by Notifiers rather than mocking Notifiers directly.
- If you must mock a Notifier, subclass the original Notifier base class instead of using
implementsorwith Mock. - Place Notifier mocks in the same file as the Notifier being mocked if code generation is used, to access generated classes.
- Use the
overridesparameter to swap out Notifiers or providers for mocks or fakes in tests. - Keep all test-specific setup and teardown logic inside the test body or test utility functions. Avoid global state.
- Ensure your test environment closely matches your production environment for reliable results.
Code Conventions¶
Naming convention¶
- Use English for naming variables, classes, methods, etc.F
- Prefix
fetchfor methods returning a Future. - Prefix
watchfor methods returning a Stream. - Use de suffix
Modelfor classes that maps a json to an Object. Generally located in/lib/src/features/feature/data/models.
Error Management strategy 🤯¶
- Add validations to every text input field.
- Apply tristate pattern (loading, error, data) for every future.
- Always provide visual loading information to the user and be sure to prevent this action from being fired again.
- Always provide visual error information to the user.
Widget Creation strategy ✨¶
- Prefer
Statelessfor static widgets. - Prefer
Statefulfor animated widgets. - Prefer
Statefulatomic Widgets to wrap Broker Widgets. Example code. - Prefer always separate the Raw Widget from the App State dependent Widget. Example code.
Do ✅ and Don'ts 🚫¶
- 🚫 Extract Widget as class functions. Self explanatory code test can be found here
- 🚫 Create one line methods that make dificult readability and maintainability.
- 🚫 Place logic pieces of code on the UI side.
- 🚫 Mix App State with Ephemeral State. Difference between both can be found here.
- 🚫 Create private widgets classes, they are not testable. The exception could be the State class of the Stateful widget.
- 🚫 Deliver code without tests.
- 🚫 Don't use non-nullable operator without previous check. Example code.
- 🚫 Don't use
Equatablewith non App State entity classes. - ✅ Use localized text and 🚫 use hardcoded text to show user information.
- ✅ Use @riverpod annotation with Futures or Streams or return AsynValue
. - ✅ Use
selectto get a value from a provider to optimez performance. More info here. - ✅ Use
constfor static widgets. - ✅ Use
finalfor widgets that will change its state. - ✅ Use
finalfor variables that will not change its state. - ✅ Use
GoRouter.of(context).push()insteadcontext.push()to navigate to another screen. Same withGoRouter.of(context).pop()andGoRouter.of(context).go(). - ✅ Always add
toEntityand overridetoStringmethod on the Model classes. Example code
Comments strategy 💬¶
-
Use BetterComments extension:
//* to help structure views with component names. //? to mark something to ask. //! to prevent the developer of something.
Big PR?? 😱¶
- It's ok, try to comment out the changes you made to make reviewer's lives easier.
- Add all the requirements or evidence of the developed task.
Test strategy 🔬¶
- Isolated tests: all the mock classes should be in the same file. Example code.
TODO strategy 😮💨¶
- Add a story for every TODO.
// TODO(UONB-XXXX): this should be refactored