Skip to main content

Development

🦺 Code Style & Best Practices

We use lints wherever feasible to have a well documented and automatically enforced set of style guides and rules (see below). However, there are a few cases where over time certain best practices and patterns have shown itself as useful and will additionally be enforced by PR reviewers throughout the entire code base. While we intend to make all of them automatic lints at some point, at this point they are show and explained here.

General Style with default lints

We generally follow the default cargo fmt-formatting & clippy rules for native, and the default flutter and dart lints on the App. You can run cargo make fixup to have the style automatically – especially when the CI complained. Beware, it doesn't fix all of them though.

All lints must be fixed or addressed (some might be ignored for that line or file if that is okay in that context) before a PR can be merged. The github action will block a merge.

💡
To locally run the CI commands to check all results, for rust do :
cargo format --all --check and cargo clippy --all -- -D warnings
and on flutter you can do the same by running in the app folder:
flutter analyze

Riverpod Best Practices

We use riverpod as our main state management system. Over the years, we have come to learn a few pitfalls and best practices to avoid shooting ourselves into the foot.

Smaller providers are better, chaining is encouraged

Ideally a provider does exactly one thing and one thing only. The more things are done in one provider the longer it takes it to evaluate and the more complicated it becomes to maintain. Instead, having smaller stupid providers that are chained by them using ref.watch internally to wait for other provider is highly encouraged.

You can also simplify the chain busing .select((x) => x.internalThing to make sure the provider doesn't trigger an evaluation if the underlying thing hasn't changed. See the docs for select to learn more.

When to use ref.read and when ref.watch

ref.read only reads the value at the given time. If it is being updated, nothing happens. While ref.watch will also observe the value and issue a re-calculation when the internal value changes. Thus, ref.read shouldn't be used in any build function, you most certainly want to use ref.watch there to ensure the UI always updates accordingly. ref.read can (and should) be used for single-instance usage, for example on events or within actions that are being executed once.

Data-based Calculations belong providers, UI stuff in the Widgets

Riverpod is great at caching, cascading and chaining state management. But it should always be based on the actual data model underneath. Not for anything of the UI itself, like the Media-Size, Theme or similar - that's what the Widgets are great at and the build-Function is made for. So, if you ever find yourself passing BuildContext to a provider, stop and think: that's not how it supposed to be, this belongs into a Widget/Consumer.

Keeping providers pure and in cascade chain has several advantages, like caching and stopping the chain as soon as thing haven't actually updated any further. BuildContext and any .of(context)-Type-thing has no reason to be there, these are purely about the way these things are presented and that can change for many reasons (e.g. switching the language, resizing the window, rotating the screen ...), which have nothing to do with the underlying data and should not that be causing it to trigger again. As data computations might be heavy, any unnecessary recalculation should be avoided.

Instead the build functions of widgets are meant to be re-evaluated many times a second and are perfectly equipped to deal exactly with that. This also avoid the problem of mixing the data-model in the providers with the visualisation model of the widget. The latter is the job of the widget and the widget only.

Avoid setState for provider data

Riverpod providers automatically update constantly, in particular because content can always come down from the server through the SDK updating the internal state of objects. When you use setState or calculate something in initState you are going to miss these updates. While the power of riverpod's state management lies within exactly those cascading updates. So avoid doing that to not have the UI run out of sync with the underlying data.

There are, however, some pretty rare circumstances in which you need to put the data into local state. Just be sure to use listenManual in initState and deal with all possible cases that need to be updated within that.

Don't requireValue

There is always cases where that is not true and due to its throwing nature this then causes entire screens to break. Do not use it, instead fall to a valueOrNull? ?? pattern.

Don't

final someData = ref.watch(someAsyncProvider).requireValue;

Do

// accept it to be a null
final someData = ref.watch(someAsyncProvider).valueOrNull;

// or with a default value:
final someList = ref.watch(someAsyncProvider).valueOrNull ?? [];

Flutter State and Widgets

don't isDesktop use actual MediaQueries

Acter is built in a responsive manner. Meaning that upon resizing the screen, we try to make the best use of the space available as possible. Checking for whether the provided platform is a desktop platform is largely misleading as a tablet in landscape setting might have the same space available. Instead of checking for isDesktop, look at MediaQuery.of(context).size or - even better - use our context.isLargeScreen helper that already checks for our common breakpoints and make things smooth across several screens.

Don't

child : isDesktop ? DesktopUI() : mobileUI(),

Do

child: context.isLargeScreen ? largeScreenUI() : mobileUI(),

Pure URLs routing only

GoRouter allows for you to pass basically anything via extra, including entire objects. Similarly, we have seen several patterns where a ref.read(someProvider.notifier).state = XYZ is being called right before routing tot the screen to set some desired internal state. Don't do that.

We assume that internal routing URLs are pure. Meaning that that we can express everything we want to happen through the given path and query parameters and any internal state management happens on the routing controller or when the Widget is initialized. As the caller, other than constructing the route, I am not expected to have any further knowledge of the internal system or its structures nor to set anything like it.

Reason: It is hard to keep track of all the spaces a route is being called from and that when updating the internal state management, all callers adhere to that new way of doing it. Similarly, it is hard for a reviewer to notice that any of those assumptions might be broken by a new caller. To avoid these scenarios, and the the following misbehaving app and bad UX, we have agreed that no such assumptions may exist and instead that all the caller "API" is and must be expressed through pure URLs only.

Bonus point: it makes it possible to share these URLs throughout the app and be sure that they work as intended even if called from anywhere else.

Colors & Fonts come from the Theme

Do not hard code any colors or types fonts. We are basing all our coloring system on the relative and dynamic theme.

Don't

color: Colors.black, 

Do

color: Theme.of(context).surfaceBackgroundColor

Guard for null instead of null checks (!)

It is a common suggested fix in most IDE's to just slap a ! behind a potential null value, if a full value is expected. Avoid that at any cost. Most IDE's and lints already pick up correctly that you already did an if (value != null) check beforehand, do that instead. Or if a default value is sufficient, supply that instead via ?? defaultValue.

Reason: _TypeError Element.widget Null check operator used on a null value are a very commonplace error we see all the time. You might assumed this wasn't ever going to be null when writing that code, but the number these errors come clearly shows we are very often wrong. Or also, that assumption might have been broken at a later point and it now break. In the base case scenario it means the screen doesn't render for the user but even then, the stack trace we get gives us essentially nothing to track down the issue. In the worst case scenario, this breaks routing or crashes the entire app.

Native rust rules

Don't unwrap in production code

While .unwrap() is fine in test code, we do not allow it in production code. It is always recommended that if a function can internally fail, that failure should bubble up by using ? instead.

Reason: .unwrap are hard failure. They crash the app, leading to a terrible user experience. Even on an I/O error or other problems that are hard failures we can't continue to operate under, it is better to tell the user about it and have them actively restart the app.

Use ? and bail! to verify user input

.unwrap, .expect and panic are equally discouraged for any case of supposedly known input values. Even if they are typed, if they can lead to a Result in unwrapping you should use ? and bail! , especially when that input can be directly manipulated by the user (as in, the user of the API).

On using .expect instead
IF you are certain this won't ever fail and thus don't want to create that overhead, you may use .expect( in those cases with a unique message describing the assumption why we'll never see that being a problem. The reason it must be unique is so that when we see that string, we can find it in the code base quickly, understand what assumption has been broken and who is to blame and should fix it (probably you for putting a .expect in there. So don't do it!)

.context(..) are for Options only

We use anyhow for simple string-based errors. However, if that is being used on an Result it covers up the internal error and gives a potentially wrong error message. .context should only be used on Option to map a None to a predictable error. If what we have is a Result, then ? or map_err(..) should be used to bubble up the actual error and stack

Don't

fn someFn(String input) -> Result<()> {
   let value = OwnedId::parse(input).context("Didn't parse");
   ...
}

Do

fn someFn(String input) -> Result<()> {
   let value = OwnedId::parse(input)?;
   ...
}

Further Tips & Tricks

Create lang to get around the build_context_await_points problem

We have several areas in the flutter code, where we use await around actions and want to show the users translated strings afterwards. The flutter lint checker (righteously) complains about that and recommends to do a if (context.mounted) before. This becomes very verbose and annoying quickly. Instead, we often only need the BuildContext because we do L10n.of(context).someLangString. So instead, what we recommend is doing a final lang = L10n.of(context); at the top of the function and just use lang.someLangString instead for those circumstances. It is less verbose and an accepted way to deal with that lint problem.