From Angular to Flutter - with love ❤️

From Angular to Flutter - with love ❤️

For me, January is the most dreaded month of the year.

Everything is dark, cold, motivation is low and you have all the existential crisis for a couple of weeks. That's me in January. Not a great presence to be around.

This year I wanted to sail against the tide.

I wanted something new, something fresh, something to make me forget about the cold atmosphere of January. Something that goes well with a cup of tea and a lot of blankets.

That's why I've started to play with Flutter for a month. Treated everything as a hackathon and jumped straight into the ocean of unknown.

In the month of Love - February, I want to present you my findings and make a comparison of how you build apps in Flutter and how you build them in Angular.

We will cover the topics of:

  • Trend

  • Dev setup

  • Mental model

  • Dependency Injection

  • State management

  • Routing

How hard can it be to switch from Angular to Flutter? 🤔


Angular vs Flutter - what does the trend says?

A good starting point is to identify what they do best.

Angular is a battle-hardened framework that stood the test of time in building web applications. Its greatest advantages are:

  • Modularity (especially nowadays with NX)

  • Component-based development

  • Opinionated - Very easy to switch from a project to another

Flutter, on the other hand, is a development toolkit for building natively compiled applications for mobile and web. Some of the advantages are:

  • Fast Development - hot reload which enables you to quickly see the changes

  • Native performance - compiles to native code

  • Dart - An easy to grasp strongly-typed language

If we do a trend comparison, we can see the up going trend of Flutter and almost going head-to-head in a trending contest.

Time to switch boats if you're an Angular developer? Not at all.

Angular had an impressive traction in enterprise companies from 2016 due to its opinionated way of work. There is a lot of code to maintain and to develop out there.


Dev setup - how do I start?

Angular setup

Angular's setup is a straight-forward one:

  1. Install Node.js and NPM

  2. Install Angular CLI globally

  3. Use Angular CLI to create a new project or install the dependencies and run an existing project

This is a setup for a generic project. It can't get easier than that.

The setup may vary depending on the nature of the project


Flutter setup

For Flutter, steps get a bit more trickier because you have three platforms that you can target: Android, iOS and Web.

That means, for iOS, you're platform dependent. You cannot compile a Flutter app to iOS if you don't do that from a macOS.

For Android and Web, you don't have this kind of limitations. You can compile them on both macOS, Linux or Windows.

iOS

Instead of writing down all the steps, I will send you to the setup guide

Android

The same for Android setup guide

From what I've seen, your best friend is going to be flutter doctor command because it will verify if you have all the setup pieces in place.

If flutter doctor is green, you are good to go and run flutter run.

As a comparison, Flutter is quite tricky to setup. Not as straight-forward as Angular.


Mental model - how do I think about it?

Building blocks

Angular

Angular building blocks revolves around three important building blocks:

  • Directives

    Decorators allows you to add (attribute directive) or change (structural directives) the DOM.

@Directive({
  selector: '[appHighlight]'
})
export class HighlightDirective {
  constructor(private el: ElementRef) {
    el.nativeElement.style.backgroundColor = 'yellow';
  }
}
/// USAGE
<p appHighlight>Highlight me!</p>
  • Components

    The components represent the main building block. It contains the logic, the template and the styling code of the component.

    You have two types

    • Dumb components - have the sole responsible of rendering data, without internal logic

    • Smart components - they are more complex and they act as wrapper across one or multiple dumb components because they contain an internal state.

Template:

<!-- todo-list.component.html -->
<div>
  <h2>Todo List</h2>
  <ul>
    <li *ngFor="let todo of todos">{{ todo }}</li>
  </ul>
</div>

Logic:

// todo-list.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-todo-list',
  templateUrl: './todo-list.component.html',
  styleUrls: ['./todo-list.component.css']
})
export class TodoListComponent {
  todos: string[] = ['Learn Angular', 'Build a todo app', 'Test the app'];
}

Usage:

<!-- app.component.html -->
<div>
  <h1>My Angular Todo App</h1>
  <app-todo-list></app-todo-list>
</div>
  • Services

    These are used for sharing data and logic across the application. They work very well with the dependency injection mechanism that Angular has out-of-the-box (OOTB).

    Their main benefits are:

    • Share data - can act as store and share data across multiple components.

    • Encapsulate logic - you can separate the data logic and the UI logic.

    • I/O actions - usually you wrap the external communications like HTTP calls or Websocket connections to separate services.


Flutter

Flutter's building blocks are not that different from Angular's. Everything in Flutter is a widget when it comes to rendering. There are two different widgets that you will encounter during development

  • Stateless Widgets

    As the name mentions, widgets that don't have state and are immutable. A good way to think about them is similar to the concept of dumb component in Angular.

    In a stateless widget your focus should be more on how you design the widgets instead of focusing on change. If you predict that there's change of data in the widget, switch to stateful widgets.

      class IngredientListItem extends StatelessWidget {
        final String name;
    
        const IngredientListItem({
          Key? key,
          required this.name,
        }) : super(key: key);
    
        @override
        Widget build(BuildContext context) {
          return Text(
            name,
          );
        }
      }
    
  • Stateful Widgets

    Here we have smart components, that contain some of the logic for keeping an internal state of the component.
    In a stateful widget your focus should be the change of data and how the UI changes in response to user's actions

    There are two classes that need to be extended.

    • StatefulWidget - here you will need to override the createState method that will return you the stateful component. You can also set inputs to this as well and pass them to the state

    • State<T> - here you will have the implementation of the UI together with the logic that involves the data change or the user interaction.

    class ShoppingList extends StatefulWidget {
      @override
      State<ShoppingList> createState() => _ShoppingListState();
    }

    class _ShoppingListState extends State<ShoppingList> {
      final _items = <Ingredient>[];
      void _addItem(Ingredient item) {
        setState(() {
          _items.add(item);
        });
      }

      void _removeItem(Ingredient item) {
        setState(() {
          _items.remove(item);
        });
      }

      @override
      Widget build(BuildContext context) {
        return Column(
          children: [
            Row(
              children: [
                Expanded(
                  child: TextField(
                    onSubmitted: (newValue) => _addItem(Ingredient(newValue)),
                    decoration: InputDecoration(
                      hintText: "Add Item",
                    ),
                  ),
                ),
                IconButton(
                  icon: Icon(Icons.add),
                  onPressed: () => _addItem(Ingredient('')), // Add empty item when button pressed
                ),
              ],
            ),
            ListView.builder(
              shrinkWrap: true,
              itemCount: _items.length,
              itemBuilder: (context, index) {
                final item = _items[index];
                return Dismissible(
                  key: Key(item),
                  onDismissed: (direction) => _removeItem(item),
                  background: Container(color: Colors.red.withOpacity(0.5)),
                  child: ListTile(
                    title: IngredientListItem(item.name),
                  ),
                );
              },
            ),
          ],
        );
      }
    }

Optionally, you can install provider package and have access to similar functionality as services in Angular


Dependency injection - how do I gain autonomy?

Angular

Angular comes with an OOTB dependency injection mechanism. This is usually applicable to services.

The only thing you will have to do is to add the decorator @Injectable to the service class. That will allow you to inject the service inside the constructor of the component, pipes, directives or services.

@Injectable({
  providedIn: 'root'
})
export class IngredientsService {
    ingredients: Ingredient[] = [
        { id: 1, name: "Salt" },
        { id: 2, name: "Peper" }
        { id: 3, name: "Olive oil" }
    ];

    getIngredients(): Ingredient[] {
        return this.ingredients;
    }
}

When you want to use the service, all you have to do is to declare it in the component constructor

@Component({
  selector: 'app-ingredients-list',
  templateUrl: './ingredients-list.component.html',
  styleUrls: ['./ingredients-list.component.css']
})
export class IngredientsListComponent implements OnInit {
  ingredients: Ingredients[] = [];

  constructor(private ingredientsService: IngredientsService) {}

  ngOnInit(): void {
    this.ingredients = this.ingredientsService.getIngredients()
  }
}

Flutter

In Flutter, unfortunately you don't have OOTB dependency functionality but that doesn't mean you don't have it at all.

For this one, you will need the provider package. You can get it by add it in the list of dependencies of pubspec.yaml

dependencies:
  provider: ^6.0.0

After you added it and installed it, all you will have to do is to create a provider that extends ChangeNotifier, containing the state.

class IngredientsProvider with ChangeNotifier {
    List<Ingredient> _ingredients = []

    List<Ingredient> get ingredients => _ingredients

    void addIngredient(Ingredient ingredient) {
        ingredients.add(ingredient);
    }
}

With the IngredientsProvider all set, we need to wrap the widget where we want to inject the service and in the build function of the widget we will inject it like this

class IngredientsList extends StatelessWidget {
    @override
    Widget build(BuildContext context) {
        final ingredientsProvider = Provider.of<IngredientsProvider>(context)

        return Column(
        children: [
        // List of ingredients
        ListView.builder(
          shrinkWrap: true,
          itemBuilder: (context, index) => Text(ingredientsProvider.ingredients[index]),
          itemCount: ingredientsProvider.ingredients.length,
        ),

        // Add ingredient button and text field
        Row(
          children: [
            Expanded(
              child: TextField(
                decoration: InputDecoration(labelText: 'Ingredient'),
                onSubmitted: (value) => ingredientsProvider.addIngredient(new Ingredient(value)),
              ),
            )
          ],
        ),
      ],
    );
    }
}

State management - what can I tell about me?

Angular

I've covered in previous articles a more in-depth view Angular's state management. I'll leave the link to the article here but for the sake of comparison, we'll cover the Observable Services.

For this, we will need to create a service that will hold and react to our data:

@Injectable({
  providedIn: 'root'
})
export class IngredientService {
  private ingredients = new BehaviorSubject<Ingredient[]>([]);
  ingredients$ = this.ingredientsSubject.asObservable();

  private addedIngredient = new Subject<Ingredient>();
  addedIngredient$ = this.addedIngredient.asObservable();

  constructor() {}

  addIngredient(ingredient: Ingredient): void {
    this.ingredients.next([...this.ingredients.getValue(), ingredient]);
    this.addedIngredient.next(ingredient);
  }
}

After that, we can use the service in the components using dependency injection:

@Component({
  selector: 'app-recipe-list',
  templateUrl: './recipe-list.component.html',
  styleUrls: ['./recipe-list.component.css'],
})
export class RecipeListComponent implements OnInit {
  ingredients: Ingredient[] = [];

  constructor(private ingredientService: IngredientService) {}

  ngOnInit(): void {
    this.ingredientService.ingredients$.subscribe((ingredients) => {
        this.ingredients = ingredients;
    });
    this.ingredientService.addedIngredient$.subscribe((ingredient) => {
      // Optional: Perform actions like highlighting newly added ingredient
    });
  }

  onAddIngredient(name: string): void {
    const newIngredient: Ingredient = {
      name: name
    };
    this.ingredientService.addIngredient(newIngredient);
  }
}

A straight-forward, RxJS based implementation I would say. The other popular approach would be to use NgRX for managing the state


Flutter

There's a lot of flexibility on state management for Flutter. Very similar to Angular's options.

If you are a fan of Observable Services in Angular, fortunately, you have the same pattern in Flutter as well using RxDart.

Unlike Angular's RxJS, RxDart doesn't come out of the box because is not embeded in the framework itself. If you want to use RxDart, make sure you add it as a dependency:

flutter pub add rxdart

After that, we can get straight into creating the service

class IngredientsBloc {
  final _ingredientsSubject = BehaviorSubject<List<Ingredient>>([]);

  Stream<List<Ingredient>> get ingredients$ => _ingredientsSubject.stream;

  final addIngredientSink = PublishSubject<Ingredient>();
  final removeIngredientSink = PublishSubject<String>();

  final addedIngredientSink = PublishSubject<Ingredient>();
  final removedIngredientSink = PublishSubject<String>();

  IngredientsBloc() {

    addIngredientSink.listen((ingredient) => {
        _ingredientsSubject.add([..._ingredientsSubject.value, ingredient]);
        addedIngredientSink.add(ingredient);
    });

    removeIngredientSink.listen((name) => {
        _ingredientsSubject.add(_ingredientsSubject.value.where((i) => i.name != name).toList());
        removedIngredientSink.add(name);
    });

  void dispose() {
    _ingredientsSubject.close();
    addIngredientSink.close();
    removeIngredientSink.close();
  }
}

Before showing you how to use the service, let's see what's similar:

  • IngredientsBloc - Bloc stands for Business Logic Components. It explicitly separates the concerns of events and data from UI. Very similar to what Observable Services can do in Angular.

  • BehaviorSubject and PublishSubject - Similar to BehaviorSubject and Observable in Angular.

  • In Angular we have the dollar suffix like addIngredient$ , in Dart I haven't seen a similar notation, but is referred as sink.

Now that we settled this, let's see how we're using it

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final ingredientsBloc = IngredientsBloc();
  StreamSubscription<Ingredient>? _addedIngredientSubscription;
  StreamSubscription<String>? _removedIngredientSubscription;

  final nameController = TextEditingController();

  @override
  void dispose() {
    super.dispose();
    ingredientsBloc.dispose();
    nameController.dispose();

    _addedIngredientSubscription.close();
    _removedIngredientSubscription.close();
  }

  @override
  Widget build(BuildContext context) {

    if (_addedIngredientSubscription == null) {
        _addedIngredientSubscription = widget.ingredientsBloc.addedIngredientSink
            .listen((addedIngredient) {
                Fluttertoast.showToast(
                    msg: "Ingredient '${addedIngredient.name}' added!",
                );
            }
    }

    if (_removedIngredientSubscription == null) {
        _removedIngredientSubscription = widget.ingredientsBloc.removedIngredientSink
            .listen((ingredientName) {
                Fluttertoast.showToast(
                    msg: "Ingredient '${ingredientName}' removed!",
                );
            }
    }

    return Scaffold(
      appBar: AppBar(
        title: Text('RxDart Ingredients'),
      ),
      body: Center(
        child: Column(
          children: [
            StreamBuilder<List<Ingredient>>(
              stream: widget.ingredientsBloc.ingredients$,
              builder: (context, snapshot) {
                if (snapshot.hasData) {
                  return ListView.builder(
                    itemCount: snapshot.data!.length,
                    itemBuilder: (context, index) {
                      final ingredient = snapshot.data![index];
                      return ListTile(
                        title: Text('${ingredient.name}'),
                        trailing: IconButton(
                          icon: Icon(Icons.delete),
                          onPressed: () => {
                            widget.ingredientsBloc.removeIngredientSink.add(ingredient.name);
                          }
                        ),
                      );
                    },
                  );
                } else {
                  return CircularProgressIndicator();
                }
              },
            ),
            SizedBox(height: 20),
            Row(
              children: [
                Flexible(
                  child: TextField(
                    controller: nameController,
                    decoration: InputDecoration(labelText: 'Ingredient Name'),
                  ),
                ),
                SizedBox(width: 10),
                ElevatedButton(
                  onPressed: () {
                    final ingredient = Ingredient(
                      name: nameController.text,
                    );

                    widget.ingredientsBloc.removeIngredientSink.add(ingredient);
                    nameController.clear();
                  },
                  child: Text('Add'),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

One thing that I liked here is how easy is to build the UI based on a stream of data instead of synchronous data. For that, you will need to use StreamBuilder and pass the data from IngredientsBloc.

This is definitely not the ultimate version, you can also do the same thing with Providers, but I will write about that in a separate article.


Beside what I've presented for both Angular and Flutter, there's always the Redux way of managing state. It's available for both of the frameworks. If you know how to use it in Angular, you will know how to use it in Flutter as well.


Routing - how do I navigate?

Angular

Angular comes with its own Router OOTB. Is well integrated with the framework and is suited for most of the scenarios that you'll encounter.

There are couple of concepts that we need to know before diving into the example:

  • Router - It acts like a traffic controller, allowing the user to move from a component to another. It's a service that can be injected to programmatically navigate. (You can do it either from template using routerLink or from .ts using Router)

  • Routes - Contains details related to path, route validation and what component to render. Here we can choose if we want to lazily load the component by using loadComponent or loadModule in case you are bounded to modules instead of stand-alone components.
    Also, it supports deep linking. You can do that by mentioning a parameter for example.

  • Router-outlet - This one represents the place in the template where you will inject the loaded component.

Let's take a look over the example below:

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <nav>
      <a routerLink="/">Home</a>
      <a routerLink="/ingredients">Ingredients (Lazy Loaded)</a>
      <a routerLink="/about">About</a>
    </nav>
    <button (click)="navigateToIngredient(id)">Navigate to ingredient</button>
    <router-outlet></router-outlet>
  `,
  providers: [
    provideRouter([
      { path: '', component: HomeComponent },
      { path: 'about', component: AboutComponent },
      { path: 'ingredients', canActivate: [IngredientsGuard], loadComponent: () => import('./ingredients/ingredients.component').then(m => m.IngredientsComponent) },
      { path: 'ingredients/:id', canActivate: [IngredientsGuard], loadComponent: () => import('./ingredient-preview/ingredient-preview.component').then(m => m.IngredientPreviewComponent) }
    ])
  ]
})
    ])
  ]
})
export class AppComponent { 

    constructor(private router: Router) {}

    navigateToIngredient(id) {
        this.router.navigate([`ingredients/${id}`])
    }
}

You don't have a lot to choose from. It's an opinionated router that will keep you covered in most of your cases.


Flutter

The documentation provides four ways of implementing the routing.

  • Using named routes

  • Using the Navigator

  • Using the Router

  • Using both Navigator and Router

We will talk about using both Navigator and Router because is more similar to Angular and it provides more OOTB functionalities like animations.

Using Router requires a third-party dependency: go_router

After we install the dependency, we can then define the routes that we'll have in the app:

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();

final router = GoRouter(
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => HomePage(),
    ),
    GoRoute(
      path: '/ingredients',
      builder: (context, state) => IngredientsPage(),
      routes: [
        GoRoute(
          path: ':id',
          builder: (context, state) => IngredientPreview(category: state.params['id']!),
        ),
      ],
    ),
    GoRoute(
      path: '/about',
      builder: (context, state) => AboutPage(),
    ),
  ],
);

We can see from the start the similarities between Angular's declaration of paths with Flutter's way.

If you want to validate the route, you can define the validation inside the builder method and navigate towards the correct page

GoRoute(
  path: '/ingredients',
  builder: (context, state) {
    if (!isAdmin(context)) {
      return Text('Access denied');
    }
    return IngredientsPage();
  },
),

Once we declared the routes, we need to add them to our app when we build the MaterialApp.

MaterialApp(
  navigatorKey: navigatorKey,
  router: router,
);

In order to navigate, we will programatically call the routing using the context that we're in.

class IngredientsPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Ingredients')),
      body: ListView.builder(
        itemCount: ingredients.length,
        itemBuilder: (context, index) {
          final ingredient = ingredient[index];
          return TextButton(
            onPressed: () => context.go('/ingredients/${ingredient.id}'),
            child: Text(ingredient.name),
          );
        },
      ),
    );
  }
}

As we can see, on the onPressed callback, we're declaring a function that uses the context.go to navigate inside the deep-link navigation.


Wrapping it up

There are many flavors that can be approached in both Angular and Flutter. You can do it in a similar fashion or you can go in totally opposite directions.

TopicAngularFlutter
TrendMature, with a considerable market share, especially in enterprise.Getting more traction, in competition with React Native and Ionic.
Dev setupA walk in the park.Platform dependent, prepare to clean-up some storage.
Mental modelComponents, directives, services.Stateless widgets, stateful widgets and providers.
Dependency InjectionOOTB through constructor-based.Constructor-based or provider-based, requires third-party.
State managementObservable Services, NgRX and Redux.Providers, Bloc Pattern and Redux.
RoutingOOTB, well integrated in the framework.Multiple methods, OOTB or decoupled by using a third-party library.

I think the important aspect is that there is a common ground from where you can start. It didn't felt like changing the whole paradigm of how I think about UI.

I thought that Dart would be a problem but it turned out to be such a reliable language to work with. The biggest annoyance is the whole parentheses chains but if you're using IDE refactoring tools they can come in handy when you extract widgets and logic.

Also, in the spirit of February, approach change and other technologies from a loving, caring and curious place.