A Practical Comparison and Lessons Learned from Building

Tim Lavreniuk

Widgetbook

Traditional Web Frameworks

Flutter Web

vs

What you can expect

160    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

React Docs quick start page
npx create-react-app grocery-list-react --template typescript
Creating a react app
Angular Docs quick start page
npm init @angular grocery-list-angular
Creating an Angular app
Flutter Docs build web app page
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)

Component types

  • Class components
  • Function components
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
Tailwind CSS for React docs
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
Tailwind CSS for Angular docs

Skia Engine

Dart Language

Material & Cupertino components

Component types

  • Stateful Widget
  • Stateless Widget
  • HookWidget*
*using 3rd party library
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

interface 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
interface 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;
  console.log(name, value);
  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" required />
       {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="••••••••" required />
       {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
const fakeAuthProvider = {
    isAuthenticated: false,
    async signIn() {
        fakeAuthProvider.isAuthenticated = true;
        return Promise.resolve();
    },
    async signOut() {
        fakeAuthProvider.isAuthenticated = false;
        return Promise.resolve();
    },
};
interface AuthContextType {
    user: IUser | null;
    signIn: (email: string, password: string) => Promise<void>;
    signOut: () => Promise<void>;
}
let AuthContext = React.createContext<AuthContextType>(null!);
AuthProvider repository and 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
let navigate = useNavigate();
let auth = useAuth();

const onSubmit = useCallback((e: React.FormEvent) => {
  e.preventDefault();
  const err = validate();
  if (!err) {
    auth.signIn(formState.email, formState.password).then(() => {
      navigate("/");
    });
  }
}, [formState, auth, validate]);
LoginPage updated onSubmit hook

SSR

@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
constructor(private router: Router, private authService: AuthService) {}

async onSubmit() {
  this.form.markAllAsTouched();
  if (this.form.valid) {
    var {email, password} = this.form.value;
    await this.authService.signIn(email!, password!);
    this.router.navigate(['/']);
  }
}
LoginPage updated onSubmit method

SSR

Angular Universal

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
  _signIn() {
    if (_formKey.currentState?.validate() ?? false) {
      _formKey.currentState?.save();
      Provider.of<AuthProvider>(context, listen: false)
          .signIn(_email, _password)
          .then((user) {
        Navigator.of(context).pushReplacementNamed('/');
      });
    }
  }
LoginPage with updated SignIn method

SSR

How to share data between views?

State

Lets create a build

Application bundle

Production bundle size

˜185 kb

˜260 kb

˜2.2 mb*

* plus CanvasKit: ˜127kb + ˜6.8mb

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
  • Good for high-performance UI
  • Crossplatform
  • Components included

Cons:

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

The end.

Flutter Web vs Traditional Web Frameworks

By Timofey Lavrenyuk

Flutter Web vs Traditional Web Frameworks

  • 199