Skip to content

Instantly share code, notes, and snippets.

@benoitjadinon
Last active December 22, 2023 20:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benoitjadinon/45fb72ca4d666e3fc8244e14cfd5c930 to your computer and use it in GitHub Desktop.
Save benoitjadinon/45fb72ca4d666e3fc8244e14cfd5c930 to your computer and use it in GitHub Desktop.
Svelte vs React vs Flutter : int stepper exercise

Svelte vs React vs Flutter

A int stepper exercise (from this great talk)

The goal is to compare technologies using a reactive form that has a 2-way twist (value can be changed manually, and be changed from different sources) results will be graded in terms of code readabilty and number of redraws needed

svelte repl

react playground

Rules

  • whether updated by increments or manually, value should only be bigger than min and smaller than max
  • incrementing max should not change the value
  • decrementing max should change the value if new max is smaller than value
  • incrementing min should change the value if new min is bigger than value
  • decrementing min should not change the value
  • changing the step updates the step buttons labels and step size
  • step buttons will increment/decrement value by their set amount

(watch out for side effects, a common issue is : decrementing the max until it changes the value, then incrementing max again. if value is not stored correctly, value will increment until reaching its previous value)

Notes

the results below are hand-sorted on (subjective) perceived readability and on how optimised they are (related to redraws)

<script>
let min = $state(10);
let max = $state(14);
let step = $state(2);
let value = $state(0);
$effect(() => value = Math.max(min, Math.min(max, value ?? min)));
</script>
min:<input type=number min=0 {max} bind:value={min} />
max:<input type=number {min} bind:value={max} />
inc:<input type=number min=0 bind:value={step} />
<br/>
<button disabled={value <= min} on:click={() => value -= step}>-{step}</button>
<input type=number {min} {max} bind:value />
<button disabled={value >= max} on:click={() => value += step}>+{step}</button>
<script>
let min = 10;
let max = 14;
let step = 2;
$:value = Math.max(min, Math.min(max, value ?? min));
</script>
min:<input type=number min=0 {max} bind:value={min} />
max:<input type=number {min} bind:value={max} />
inc:<input type=number min=0 bind:value={step} />
<br/>
<button disabled={value <= min} on:click={() => value -= step}>-{step}</button>
<input type=number {min} {max} bind:value />
<button disabled={value >= max} on:click={() => value += step}>+{step}</button>
<script>
import {derived, writable} from "svelte/store";
const min = writable(10);
const max = writable(14);
const step = writable(2);
const value = writable($min)
const valueCalc = derived([value, min, max], ([val, min, max]) => {
const valCalc = Math.max(min, Math.min(max, val));
value.set(valCalc);
return valCalc;
});
</script>
min:<input type=number min=0 {max} bind:value={$min} />
max:<input type=number {min} bind:value={$max} />
inc:<input type=number min=0 bind:value={$step} />
<br/>
<button disabled={$valueCalc <= $min} on:click={() => value.set($valueCalc - $step)}>-</button>
<input type=number {min} {max} value={$valueCalc} on:change={(e) => value.update((_) => e.target.value)} />
<button disabled={$valueCalc >= $max} on:click={() => value.set($valueCalc + $step)}>+</button>
import {Signal, useSignal/*, useSignalEffect*/} from "@preact/signals-react";
// optimised, 1 render per action
export default function App() {
const min = useSignal(10);
const max = useSignal(14);
const step = useSignal(2);
const val = useSignal(10);
val.value = Math.max(min.value, Math.min(max.value, val.value ?? min.value));
// typescript helper to strong-type (and shorten) input onChange handlers
const onChange = (setter:Signal<number>) => (e: React.ChangeEvent<HTMLInputElement>) => setter.value = parseInt(e.target.value);
return (
<>
min:<input type="number" value={min.value} min="0" max={max.value} onChange={onChange(min)} />
max:<input type="number" value={max.value} min={min.value} onChange={onChange(max)} />
inc:<input type="number" value={step.value} min="0" onChange={onChange(step)} />
<br />
<button disabled={val.value <= min.value} onClick={() => val.value -= step.value}>-{step.value}</button>
<input type="number" value={val.value} onChange={onChange(val)} />
<button disabled={val.value >= max.value} onClick={() => val.value += step.value}>+{step.value}</button>
</>
);
}
import {useState, useEffect} from "react";
// non-optimised : side-effects in this one, re-renders from 1 to 3 times depending on action
export default function App() {
const [min, setMin] = useState(10);
const [max, setMax] = useState(14);
const [step, setStep] = useState(2);
const [value, setValue] = useState(min);
useEffect(() => setValue(Math.max(min, Math.min(max, value ?? min))), [min,max,value]);
// typescript helper to strong-type (and shorten) input onChange handlers
const onChange = (setter:React.Dispatch<React.SetStateAction<number>>) => (e: React.ChangeEvent<HTMLInputElement>) => setter(parseInt(e.target.value))
return (
<>
min:<input type="number" value={min} min="0" max={max} onChange={onChange(setMin)} />
max:<input type="number" value={max} min={min} onChange={onChange(setMax)} />
inc:<input type="number" value={step} min="0" onChange={onChange(setStep)} />
<br />
<button disabled={value <= min} onClick={() => setValue(value - step)}>-{step}</button>
<input type="number" value={value} min={min} max={max} onChange={onChange(setValue)} />
<button disabled={value >= max} onClick={() => setValue(value + step)}>+{step}</button>
</>
);
}
import {useState} from "react";
// optimised : no side-effects in this one, only 1 re-render per action
export default function App() {
const [min, setMin] = useState(10);
const [max, setMax] = useState(14);
const [step, setStep] = useState(2);
const [value, setValue] = useState(min);
const valCalc = Math.max(min, Math.min(max, value));
const setValueInBounds = (newValue:number, minOverride?, maxOverride?) => {
const valInBounds = Math.max(minOverride ?? min, Math.min(maxOverride ?? max, newValue));
if (valInBounds !== valCalc) setValue(valInBounds);
}
// typescript helper to strong-type (and shorten) input onChange handlers
const onChange = (setter: React.Dispatch<React.SetStateAction<number>>, affectValue?: 'min'|'max') => (e: React.ChangeEvent<HTMLInputElement>) => {
const fieldValue = parseInt(e.target.value);
// batched updates : only one render
setter(fieldValue);
if (affectValue) setValueInBounds(value, affectValue === 'min' ? fieldValue : min, affectValue === 'max' ? fieldValue : max);
}
return (
<>
min:<input type="number" value={min} min="0" max={max} onChange={onChange(setMin, 'min')} />
max:<input type="number" value={max} min={min} onChange={onChange(setMax, 'max')} />
inc:<input type="number" value={step} min="0" onChange={onChange(setStep)} />
<br />
<button disabled={valCalc <= min} onClick={() => setValueInBounds(valCalc - step)}>-{step}</button>
<input type="number" value={valCalc} min={min} max={max} onChange={onChange(setValueInBounds)} />
<button disabled={valCalc >= max} onClick={() => setValueInBounds(valCalc + step)}>+{step}</button>
</>
);
}
import React from "react";
import {bind, Subscribe} from "@react-rxjs/core"
import {createSignal} from "@react-rxjs/utils"
import {combineLatestAll, combineLatestWith, distinct, distinctUntilChanged, map, min, startWith, tap} from "rxjs";
export default function App() {
const [minChange$, setMin] = createSignal<number>();
const [useMin, min$] = bind(minChange$, 10);
const [maxChange$, setMax] = createSignal<number>();
const [useMax, max$] = bind(maxChange$, 14);
const [stepChange$, setStep] = createSignal<number>();
const [useStep, step$] = bind(stepChange$, 2);
const [valueChange$, setValue] = createSignal<number>();
const [useValue, value$] = bind(valueChange$, useMin());
const [useValueCalculated, valueCalculated$] = bind(value$.pipe(
combineLatestWith(min$),
combineLatestWith(max$),
map(([[val, min], max]) => Math.max(min, Math.min(max, val))),
distinctUntilChanged(),
tap(v => setValue(v)), // side effect
), useValue());
// typescript helper to strong-type (and shorten) input onChange handlers
const onChange = (setter: (a: number) => void) => (e: React.ChangeEvent<HTMLInputElement>) => setter(parseInt(e.target.value))
function Variables() {
const min = useMin()
const max = useMax()
const step = useStep()
return (
<>
min:<input type="number" value={min} min="0" max={max} onChange={onChange(setMin)}/>
max:<input type="number" value={max} min={min} onChange={onChange(setMax)}/>
max:<input type="number" value={step} min={0} onChange={onChange(setStep)}/>
</>
);
}
function Form() {
const min = useMin()
const max = useMax()
const step = useStep()
const value = useValueCalculated()
return (
<>
<button disabled={value <= min} onClick={() => setValue(value - step)}>-{step}</button>
<input type="number" value={value} min={min} max={max} onChange={onChange(setValue)}/>
<button disabled={value >= max} onClick={() => setValue(value + step)}>+{step}</button>
</>
);
}
return (
<>
<Subscribe>
<Variables/>
<br/>
<Form/>
</Subscribe>
</>
);
}
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:number_inc_dec/number_inc_dec.dart';
const useController = useTextEditingController;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: Scaffold(
body: Stepper(),
),
);
}
}
class Stepper extends HookWidget {
const Stepper({super.key});
int controllerValue(TextEditingController controller) =>
int.parse(controller.value.text);
@override
Widget build(BuildContext context) {
final contMin = useController.fromValue(const TextEditingValue(text: '10'));
final contMax = useController.fromValue(const TextEditingValue(text: '14'));
final contInc = useController.fromValue(const TextEditingValue(text: '2'));
final contVal = useController.fromValue(const TextEditingValue(text: '0'));
useEffect(() {
contVal.text = controllerValue(contVal)
.clamp(controllerValue(contMin), controllerValue(contMax))
.toString();
return;
}, [
useValueListenable(contMin),
useValueListenable(contMax),
useValueListenable(contVal),
]);
useValueListenable(contInc);
return Column(
children: [
Row(
children: [
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: contMin,
initialValue: controllerValue(contMin),
max: controllerValue(contMax),
),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: contMax,
initialValue: controllerValue(contMax),
min: controllerValue(contMin),
),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: contInc,
initialValue: controllerValue(contInc),
min: 0,
),
),
],
),
Row(
children: [
TextButton(
onPressed: controllerValue(contVal) <= controllerValue(contMin)
? null
: () => contVal.text =
(controllerValue(contVal) - controllerValue(contInc))
.toString(),
child: Text('- ${controllerValue(contInc)}'),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: contVal,
initialValue: controllerValue(contVal),
min: controllerValue(contMin),
max: controllerValue(contMax),
),
),
TextButton(
onPressed: controllerValue(contVal) >= controllerValue(contMax)
? null
: () => contVal.text =
(controllerValue(contVal) + controllerValue(contInc))
.toString(),
child: Text('+ ${controllerValue(contInc)}'),
),
],
),
],
);
}
}
import 'package:flutter/material.dart';
import 'package:number_inc_dec/number_inc_dec.dart';
import 'dart:math' as math;
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Stepper(),
),
);
}
}
class Stepper extends StatefulWidget {
@override
State<Stepper> createState() => _StepperState();
}
class _StepperState extends State<Stepper> {
int min = 10;
int max = 14;
int step = 2;
int value = 0;
TextEditingController minController = TextEditingController();
TextEditingController maxController = TextEditingController();
TextEditingController stepController = TextEditingController();
TextEditingController valueController = TextEditingController();
@override
void initState() {
super.initState();
value = min;
minController.addListener(
() {
min = int.parse(minController.text);
update();
},
);
maxController.addListener(
() {
max = int.parse(maxController.text);
update();
},
);
stepController.addListener(() {
step = int.parse(stepController.text);
update();
});
valueController.addListener(
() => update(int.parse(valueController.text)),
);
}
void update([int? expectedValue]) {
value = math.max(min, math.min(max, expectedValue ?? value));
valueController.text = value.toString();
setState(() {});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: minController,
initialValue: min,
min: 0,
max: max,
),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: maxController,
initialValue: max,
min: min,
),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: stepController,
initialValue: step,
min: 0,
),
),
],
),
Row(
children: [
TextButton(
onPressed: value <= min ? null : () => update(value - step),
child: Text('-$step'),
),
SizedBox(
width: 100,
child: NumberInputWithIncrementDecrement(
controller: valueController,
initialValue: value,
min: min,
max: max,
),
),
TextButton(
onPressed: value >= max ? null : () => update(value + step),
child: Text('+$step'),
),
],
),
],
);
}
}
@benoitjadinon
Copy link
Author

benoitjadinon commented Sep 2, 2021

@benoitjadinon
Copy link
Author

@benoitjadinon
Copy link
Author

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment