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