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