A Practical Comparison and Lessons Learned from Building

Tim Lavreniuk

Widgetbook

Traditional Web Frameworks

Flutter Web

vs

What you can expect

Many  slides

Bad humor

Many code examples

You want to build a web app

Why should you use a Web Framework?

  • Faster development
  • Complex UI
  • Easy maintenance
  • ...

What web framework to choose?

React

  • First released in 2013 by Facebook
  • Library for building user interfaces
  • Designed to build a wide range of applications

Angular

  • First released in 2010 by Google
  • Framework for building dynamic web applications
  • Designed to build complex and large-scaling web apps

Flutter

  • First released in 2017 by Google
  • UI toolkit and framework for building cross-platform apps
  • Uses the Dart programming language
  • Designed to build natively compiled applications

Let's create an app

Grocery Shopping List App

Quick start

npx create-react-app grocery-list-react --template typescript
Creating a react app
npm init @angular grocery-list-angular
Creating an Angular app
brew install --cask flutter

installing flutter sdk

flutter create grocery_list_flutter

creating an app

UI

JSX

Virtual DOM

const element = <h1 className="greetings">Hello, world!</h1>;
JSX (before transpiling)
const element = React.createElement(
  'h1',
  {className: 'greeting'},
  'Hello, world!'
);
JSX (after transpiling)
function HomePage() {
    const items = [{ name: 'Milk' }, { name: 'Apple' }];
    return (
        <div>
            {items.map(item => (
                  <div>
                      <span>{item.name}</span>
                      <img src={deleteIconSrc}/>
                  </div>
            ))}
            <button>
                <img width="10px" src={addIconSrc}/>
                <input type="text" placeholder="add a new list"/>
            </button>
        </div>
    );
}

export default HomePage;
HomePage component

Adding styles to component

  • Inline CSS
  • Normal CSS
  • CSS in JS
  • Styled Components
  • CSS modules
  • Sass & SCSS
  • Less
  • Stylable
  • Utility classes
function HomePage() {
    const items = [{ name: 'Milk' }, { name: 'Apple' }];
    return (
        <div className="flex items-center justify-center w-screen h-screen font-medium">
            <div className="flex flex-grow items-center justify-center h-full text-gray-600 bg-gray-100">
                <div className="max-w-full p-8 bg-white rounded-lg shadow-lg w-96">
                    {
                        items.map(item => (<div>
                        <div className="flex items-center h-10 px-2 rounded cursor-pointer hover:bg-gray-100">
                                <span className="text-left text-sm flex-1">{item.name}</span>
                                <span className="flex items-center justify-center w-5 h-5 text-transparent ml-5">
                                    <img width="10px" src={deleteIconSrc}/>
                                </span>
                            </div>
                        </div>))
                    }
                    <button className="flex items-center w-full h-8 px-2 mt-2 text-sm font-medium rounded">
                        <img width="10px" src={addIconSrc}/>
                        <input className="flex-grow h-8 ml-4 bg-transparent border-0 focus:outline-none font-medium"
                               type="text" placeholder="add a new item"/>
                    </button>
                </div>
            </div>
        </div>
    );
}
HomePage component with TailwindCSS classes

Extended HTML

Framework

Angular CLI component and module generation
@NgModule({
  declarations: [
      HomePageComponent
  ],
  imports: [
    CommonModule
  ]
})
export class HomePageModule { }
Angular Module declaration
import { Component } from '@angular/core';

@Component({
  selector: 'home-page',
  templateUrl: './home-page.component.html',
  styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent {
  items = [{ name: 'Milk' }, { name: 'Apple' }];
  
  trackById(index: number, item: IItem) {
    return item.id
  }
}
HomePage component class
<div>
    <div *ngFor="let item of items; trackBy:trackById">
        <div>
            <span>{{item.name}}</span>
	    <svg width="10px" ... ></svg>
        </div>
    </div>
    <button>
        <svg width="10px" ... ></svg>
        <input type="text" placeholder="add a new list"/>
    </button>
</div>
HomePage component HTML template

Skia Engine

Dart Language

Material & Cupertino components
class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
  final List<String> _groceryList = [ 'Milk', 'Apple'];
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: _groceryList.length,
              itemBuilder: (context, index) {
                return ListTile(
                  title: Text(_groceryList[index]),
                  trailing:
                    IconButton(
                      onPressed: () {},
                      icon: const Icon(Icons.remove_circle),
                    ),
                );
              },
            ),
          ),
}
HomePage widget
     Container(
        padding: EdgeInsets.all(8.0),
        child: Row(
          children: [
            IconButton(onPressed: () {}, icon: const Icon(Icons.add)),
            Expanded(
              child: TextField(
                decoration: const InputDecoration(
                  hintText: 'add a new item',
                ),
              ),
            ),
          ],
        ),
      )],
    ),
  );
}
HomePage widget

How to simplify a code?

Components

item property
onDelete callback

ListItem

onAdd callback

AddItemButton

Hooks

type IListItemProps = {
    item: IItem;
    onDelete: (id: string) => void;
}
function ListItem({item, onDelete}: IListItemProps) {
    const onClick = useCallback(() => onDelete(item.id), [item]);
    return (
        <div>
            <div className="...">
                <span className="...">{item.name}</span>
                <span onClick={onClick} className="...">
                    <img width="10px" src={deleteIconSrc}/>
                </span>
            </div>
        </div>
    );
}
export default ListItem;
ListItem component with hook
type IAddNewsItemProps = {
    onAdd: (name: string) => void;
}
const AddNewItem = ({onAdd}: IAddNewsItemProps) => {
    const [value, setValue] = React.useState<string>('');
    const onChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
        setValue(e.currentTarget.value);
    }, [value]);
    const onClick = useCallback(() => {
        onAdd(value);
        setValue('');
    }, [value]);
    const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
        if (e.key === 'Enter') {
            onAdd(value);
            setValue('');
        }
    }, [value, onAdd]);
    return (
        <div className="...">
            <img onClick={onClick} width="20px" src={addIconSrc}/>
            <input onKeyDown={onKeyDown} value={value} onChange={onChange} ... />
        </div>
    )
}
export default AddNewItem;
AddNewItem component with hooks
function HomePage() {
    const [items, setItems] = useState<IItem[]>([]);
    const onDelete = useCallback((id: string) => {
        setItems(items.filter(i => i.id !== id));
    }, [items]);
    const onAdd = useCallback((name: string) => {
        setItems([...items, { id: Math.random().toString(), name }]);
    }, [items]);
    return (
        <div className="...">
            <div className="...">
                <div className="...">
                    { items.map(item => <ListItem item={item}
                                                  onDelete={onDelete} />) }
                    <AddNewsItem onAdd={onAdd} />
                </div>
            </div>
        </div>
    );
}
export default HomePage;
HomePage component with hooks

Infinite List

by default

@NgModule({
  declarations: [
    HomePageComponent,
    ListItemComponent,
    AddNewItemComponent
  ],
  exports: [
    HomePageComponent,
    ListItemComponent,
    AddNewItemComponent
  ],
  imports: [
    CommonModule,
    BrowserModule
  ]
})
export class HomePageModule { }
Components declaration inside HomePage module

Directives

@Component({
  selector: 'list-item',
  templateUrl: './list-item.component.html',
  styleUrls: ['./list-item.component.scss']
})
export class ListItemComponent {
  @Input() item!: IItem;

  @Output() onDelete = new EventEmitter();
}
<div class="...">
    <span class="...">{{item.name}}</span>
    <span (click)="onDelete.emit()" class="...">
        <svg>...</svg>
    </span>
</div>
ListItemComponent class and template
@Component({
    selector: 'add-new-item',
    templateUrl: './add-new-item.component.html',
    styleUrls: ['./add-new-item.component.scss']
})
export class AddNewItemComponent {
    @Output() onAdd = new EventEmitter<string>();
}
<div class="...">
    <svg (click)="onAdd.emit(newItem.value);newItem.value = '">...</svg>
    <input #newItem
           (keydown.enter)="onAdd.emit(newItem.value);newItem.value = ''" ... />
</div>
AddNewItem component class and template
@Component({
  selector: 'home-page',
  templateUrl: './home-page.component.html',
  styleUrls: ['./home-page.component.scss']
})
export class HomePageComponent {
  items: IItem[] = [];

  trackById(index: number, item: IItem) {
    return item.id
  }
  onDelete(id: string) {
    this.items = this.items.filter(i => i.id !== id);
  }
  onAdd(name: string) {
    this.items.push({id: Date.now().toString(), name});
  }
}
HomePage component class
<div class="...">
    <div class="...">
        <div class="...">
            <list-item
                    *ngFor="let item of items; trackBy: trackById"
                    [item]="item"
                    (onDelete)="onDelete(item.id)"
            >
            </list-item>
            <add-new-item (onAdd)="onAdd($event)"></add-new-item>
        </div>
    </div>
</div>
HomePage component template

Infinite List

by default

class ListItem extends StatelessWidget {
  final Item item;
  final Function(String id) onDelete;

  const ListItem({Key? key, required this.item, required this.onDelete}):
        super(key: key);
        
  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(item.name),
      trailing:
        IconButton(
          onPressed: () {
            onDelete(item.id);
          },
          icon: const Icon(Icons.remove_circle),
        ),
    );
  }
}
ListItem widget
class AddNewItem extends StatelessWidget {
  AddNewItem({Key? key, required this.onAdd}) : super(key: key);
  final Function(String id) onAdd;
  final TextEditingController _textController = TextEditingController();
  @override
  Widget build(BuildContext context) {
    return Container(
        padding: const EdgeInsets.all(8.0),
        child: Row(
          children: [
            IconButton(onPressed: () {
              onAdd(_textController.text);
            }, icon: const Icon(Icons.add)),
            Expanded(
              child: TextField(
                controller: _textController,
                onSubmitted: (value) {
                  onAdd(_textController.text);
                  FocusScope.of(context).previousFocus();
                },
                decoration: const InputDecoration(
                  hintText: 'add a new item',
                ),
              ),
            ),
          ],
        ),
      );
  }
}
AddNewItem widget
class _HomePageState extends State<HomePage> {
  final List<Item> _items = [];

  void onDelete(String id) {
    setState(() {
      _items.removeWhere((item) => item.id == id);
    });
  }

  void onAdd(String name) {
    setState(() {
      _items.add(Item(id: DateTime.now().toString(), name: name));
    });
  }
HomePage widget state
@override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
                itemCount: _items.length,
                itemBuilder: (context, index) => ListItem(
                    key: Key(_items[index].id),
                    item: _items[index],
                    onDelete: onDelete)),
          ),
          AddNewItem(onAdd: onAdd,)
        ],
      ),
    );
  }
HomePage widget build

Infinite List

included by default

What about login?

Login page

Forms

export function LoginPage() {
    const [formState, setFormState] = useState({
        email: "",
        emailError: "",
        password: "",
        passwordError: ""
    });

    const validate = useCallback(() => {
        let isError = false;
        const emailRegex = /\S+@\S+\.\S+/;
        if (!formState.email) {
            isError = true;
            setFormState((prevState) =>
                ({...prevState, emailError: "Email is required"}));
        } else if (!emailRegex.test(formState.email)) {
            isError = true;
            setFormState((prevState) =>
                ({...prevState, emailError: "Email is invalid"}));
        }
        if (!formState.password) {
            isError = true;
            setFormState((prevState) =>
                ({...prevState, passwordError: "Password is required"}));
        }
        return isError;
    }, [formState]);
Login Page with state and validation
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const {name, value} = e.target;
  setFormState((prevProps) => ({
    ...prevProps,
    [name]: value,
    emailError: "",
    passwordError: ""
  }));
}
const onSubmit = (e: React.FormEvent) => {
  e.preventDefault();
  const err = validate();
  if (!err) {
    console.log("Form submitted successfully");
  }
};
Login Page handlers
 return (
  <form onSubmit={onSubmit} noValidate className="...">
     <div>
       <label htmlFor="email" className="...">
         Your email
       </label>
       <input type="email" onChange={onChange} name="email" id="email" className="..." placeholder="name@company.com" />
       {formState.emailError && <p className="...">{formState.emailError}</p>}
     </div>
     <div>
       <label htmlFor="password" className="...">
         Password
       </label>
       <input type="password" onChange={onChange} name="password" id="password" className="..." placeholder="••••••••" />
       {formState.passwordError && <p className="...">{formState.passwordError}</p>}
     </div>
     <button type="submit" className="...">Sign in</button>
  </form>
);
Login Page JSX

Form approaches

  • Template-driven forms
  • Reactive forms
@Component({
  selector: 'login-page',
  templateUrl: './login-page.component.html',
  styleUrls: ['./login-page.component.scss']
})
export class LoginPageComponent {
  form = new FormGroup({
    email: new FormControl('', [Validators.required, Validators.email]),
    password: new FormControl('', [Validators.required])
  });

  onSubmit() {
    this.form.markAllAsTouched();
    if (this.form.valid) {
      console.log('Form submitted successfully');
    }
  }
}
LoginPage class with reactive form
<form [formGroup]="form" (ngSubmit)="onSubmit()" class="...">
  <div>
    <label class="..." for="email">Your email</label>
    <input formControlName="email" class="..."
           id="email" name="email"type="email"/>
    <div *ngIf="form.controls.email.touched && form.controls.email.hasError('required')" class="...">
      Email is required
    </div>
    <div *ngIf="form.controls.email.touched && form.controls.email.hasError('email')" class="...">
      Invalid email address
    </div>
  </div>
  <div>
    <label class="..." for="password">Password</label>
    <input formControlName="password" class="..."
           id="password" name="password" type="password"/>
    <p *ngIf="form.controls.password.touched && form.controls.password.hasError('required')" class="...">
      Password is required
    </p>
  </div>
  <button class="..." type="submit">
    Sign in
  </button>
</form>
LoginPage template with form bindings

Angular Forms

Regular Forms

class _LoginPageState extends State<LoginPage> {
  final _formKey = GlobalKey<FormState>();
  String _email = '';
  String _password = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Form(
          key: _formKey,
          child: Column(
            children: <Widget>[
LoginPage state with formKey and Form
TextFormField(
  keyboardType: TextInputType.emailAddress,
  decoration: const InputDecoration(labelText: 'Email'),
  validator: (value) {
    if (value != null && value.isEmpty) {
      return 'Email is required';
    }
    if (value != null && !RegExp(r"^[a-zA-Z0-9").hasMatch(value)) {
    	return 'Invalid email address';
    }
    return null;
  },
  onSaved: (value) => _email = value ?? '';
),
LoginPage FormField
 Padding(
   padding: const EdgeInsets.symmetric(vertical: 16.0),
   child: MaterialButton(
     onPressed: () {
       if (_formKey.currentState != null && _formKey.currentState!.validate()) {
         _formKey.currentState!.save();
         print('Form submitted successfully');
       }
     },
     child: const Text('Sign in'),
   ),
 ),
LoginPage widget sign in button

How to navigate?

Routing

Routing

by default

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
      <BrowserRouter>
        <App />
      </BrowserRouter>
  </React.StrictMode>
);
index.tsx
type AuthContextType = {
    user: IUser | null;
    signIn: (email: string, password: string) => Promise<void>;
    signOut: () => Promise<void>;
}
let AuthContext = React.createContext<AuthContextType>(null!);
AuthProvider context
export function AuthProvider({ children }: { children: React.ReactNode }) {
    let [user, setUser] = React.useState<IUser | null>(null);
    let signIn = (email: string, password: string) => {
        return fakeAuthProvider.signIn(email, password).then(() => {
            const newUser = { id: 'test', email };
            setUser(newUser)
        });
    };
    let signOut = () => {
        return fakeAuthProvider.signOut().then(() => {
            setUser(null);
        });
    };
    let value = { user, signIn, signOut };
    return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
AuthProvider component with state and methods
export function RequireAuth({ children }: { children: JSX.Element }) {
    let auth = useAuth();
    if (!auth.user) {
        return <Navigate to="/login" replace />;
    }
    return children;
}
export function useAuth() {
    return React.useContext(AuthContext);
}
RequireAuth component
useAuth hook
function App() {
  return (
    <AuthProvider>
        <Routes>
            <Route element={<Outlet />}>
                <Route path="/login" element={<LoginPage />} />
                <Route
                    path="/"
                    element={
                        <RequireAuth>
                            <HomePage />
                        </RequireAuth>
                    }
                />
            </Route>
        </Routes>
    </AuthProvider>
  );
}
function App() {
  return (
    <LoginPage />
  );
}
App component without routing
App component with routing

Meta framework

@Injectable()
export class AuthService {
  user: IUser | null = null;
  signIn(email: string, password: string): Promise<IUser> {
    this.user = {
      id: 'test',
      email
    }
    return Promise.resolve(this.user);
  }
  signOut(): Promise<void> {
    this.user = null;
    return Promise.resolve();
  }
}
AuthService provider class
@Injectable()
export class AuthGuard implements CanActivate  {
  constructor(private authService: AuthService,
              private router: Router) {}
  canActivate() {
    if (this.authService.user == null) {
      this.router.navigate(['/login']);
      return false;
    }
    return true;
  }
}
AuthGuard
const routes: Routes = [
  {
    path: '',
    component: HomePageComponent,
    canActivate: [AuthGuard]
  },
  {
    path: 'login',
    component: LoginPageComponent,
  },
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }
app-routing.module.ts
@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    HomePageModule,
    LoginPageModule
  ],
  providers: [
    AuthService,
    AuthGuard
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }
<router-outlet></router-outlet>
app.component.html
app.module.ts
class AuthProvider with ChangeNotifier {
  User? user;
  Future<User> signIn(String email, String password) async {
    user = User(id: 'test', email: email);
    notifyListeners();
    return Future.value(user);
  }
  Future<void> signOut() async {
    user = null;
    notifyListeners();
    return Future.value();
  }
}
AuthProvider
class RequiredAuth extends StatefulWidget {
  final Widget child;

  const RequiredAuth({Key? key, required this.child}) : super(key: key);

  @override
  _RequiredAuthState createState() => _RequiredAuthState();
}

class _RequiredAuthState extends State<RequiredAuth> {
  Future<bool> _checkAuth() async {
    AuthProvider authProvider = Provider.of(context);
    return authProvider.user != null;
  }
RequireAuth widget with AuthProvider
  @override
  Widget build(BuildContext context) {
    return FutureBuilder<bool>(
      future: _checkAuth(),
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          if (snapshot.data == true) {
            return widget.child;
          } else {
            Future.delayed(Duration.zero, () {
              Navigator.of(context).pushReplacementNamed('/login');
            });
            return Container();
          }
        } else {
          return Container();
        }
      },
    );
  }
AuthService widget build
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Grocery List App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: LoginPage(),
    );
  }
}
main.dart without routing
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AuthProvider>(
        create: (_) => AuthProvider(),
    child: MaterialApp(
      title: 'Grocery List App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      routes: {
        '/': (context) => RequiredAuth(child: HomePage()),
        '/login': (context) => LoginPage(),
      },
    ));
  }
}
main.dart with routing

Lets build our app

Application bundle

Production bundle size

˜185 kb

˜260 kb

˜2.2 mb*

* plus CanvasKit

Lines of code amount

˜300

˜290*

˜280

* ˜30% of code - autogenerated

Conclusions

What is a minimum set of tools to build a complex app?

Need a components library

Need a state management library

Need a routing library

Need a form utilities library

Need a state management library

Need a components library

Need a state management library

Need a routing library

Need a form utilities library

What web framework should you choose?

Pros:

  • Easy to create complex UI
  • Large community
  • Fast learning curve

Cons:

  • It's just a library

Pros:

  • Complete framework
  • Easy to build and scale large apps
  • Easy to understand and maintain code

Cons:

  • Difficult for new developers to get started
  • Can be heavy and slow

Pros:

  • Dart language
  • Suitable for building high-performance UI
  • Cross-platform
  • Components included

Cons:

  • Difficult for new developers to get started
  • Very heavy for web apps

The end.

Copy of Flutter Web vs Traditional Web Frameworks

By Timofey Lavrenyuk

Copy of Flutter Web vs Traditional Web Frameworks

  • 57