Tackle React component Complexity
using
Reactive StateCharts
Farzad Yousef Zadeh
@farzad_yz
✈ Aerospace engineer 🔭 Astrophysicist
Senior Software Engineer @
CurrentState
PreviousState
github.com/farskid
twitter.com/farzad_yz
🇫🇮
Taking many parameters into account
Branching
Cyclomatic Complexity
Parameters
Cyclomatic Complexity
How the software is supposed to work without talking about how it's implemented
The user is supposed to write an email in the search input
The search input is supposed to validate the email
The search input is supposed to query emails from the server
The querying should happen when the user has stopped typing
When there is no email available, the user should see a sad panda picture
When there is some error in querying, show a red text describing what went wrong.
( Part of )
State
State
Initial State
Event
Event
Event
Event
f(state, event) => state
Finite state Machine
Make Nested machines
Make Parallel Machines
Conditional Transitions
Store Non-concrete state
&& data
Things can happen only if other things have happened before
Things can live independently in the same context
Guard against invalidity / Branching logic
Model non-concrete concepts e.g. Animations / Internal data store
Submit form only after the input is validated
Twitter Feed vs Side Widget
Red input when invalid / Green input when valid
Battery percentage / List of countries / Server Error
State Structure
Support Entry Actions
Support Exit Actions
Actions to be executed when you enter a state (Good for initializing stuff)
Actions to be executed when you exit a state (Good for cleaning up)
Start off a fetch controller upon entering the pending state
Cancel the fetch upon exiting pending state
...More
type State = {
isLoading: boolean;
accounts: Account[];
error?: any;
};
type State1 =
| "Idle"
| "Loading"
| "Success"
| "Error";
We're used to
in the FSM world
1
2
3
4
Serializable
No surprise
👏🎹 @davidkpiano
<DebouncedInput onChange={console.log} delay={800} />
const [value, setValue] = useState("");
const [loading, setLoading] = useState(false);
function onChange(value) {
props.onChange(value);
setValue(value);
}
useEffect(() => {
if (value) {
setLoading(true);
try {search(value)} catch(err) {} finally {
setLoading(false);
}
}
}, [value])
return (
<>
<input
value={value}
onChange={e => debounce(onChange, props.delay)(e.target.value)}
/>
{loading && <Spinner />}
</>
);
const [state, sendEvent] =
useMachine(
inputMachine.withConfig({
delays: {
DEBOUNCE: props.delay
}
})
);
function onChange(value) {
props.onChange(value);
sendEvent({
type: "VALUE",
data: value
})
}
return (
<>
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
{state.matches("search") && <Spinner />}
</>
);
{
initial: "waiting",
states: {
waiting: {
on: {
VALUE: "debouncing"
}
},
debouncing: {
entry: "updateValue",
on: {
VALUE: "debouncing",
},
after: {
DEBOUNCE: [
{
target: "waiting",
cond: "isValueEmpty"
},
{target: "search"}
]
}
},
search: {}
}
}
View React
Machine
Abstract
Delayed Transitions
V = F (S)
<DebouncedInput onChange={console.log} delay={800} />
const [value, setValue] = useState("");
function onChange(value) {
props.onChange(value);
setValue(value);
}
useEffect(() => {
if (value) {
const ctrl = new AbortController();
search(value, {signal: ctrl.signal});
}
return () => {
ctrl.abort();
}
}, [value])
return (
<input
value={value}
onChange={e => debounce(onChange, props.delay)(e.target.value)}
/>
);
{
initial: "waiting",
states: {
...same,
search: {
entry: assign({ctrl: () => new AbortController()}),
invoke: {
src: "searchService",
onDone: "",
onError: ""
},
exit: [
ctx => ctx.ctrl.abort(),
assign({ctrl: () => undefined})
],
on: {
VALUE: "debouncing"
}
}
}
}
makeNewController
Invoked promises
Initialize a controller
Clean up
Cancellation
cancelSearch
cleanupController
<Form onSubmit={} onValidating={} />
const [value, setValue] = useReducer(reducer, {
isValidating: false,
isSubmitting: false,
error: undefined,
touchedInputs: []
});
Form State
Extended State
Actual Data
isValidating: true, isSubmitting: true
isValidating: true, isSubmitting: false
isValidating: false, isSubmitting: true
isValidating: false, isSubmitting: false
Form is idle
Form is being submitted
Form is being validated
WTF ???
{
initial: "idle",
context: {error: undefined, touchedInputs: []},
states: {
idle: {
on: {
VALUE: {
target: "validating"
}
}
},
validating: {
entry: ["markChangedInputAsTouched", "updateChangedInputValue"],
on: {
VALUE: "validating",
'': [
{target: "valid", cond: "inputsAreValid"},
{target: "invalid"}
]
}
},
valid: {
on: {
VALUE: "validating",
SUBMIT: "submitting"
}
},
invalid: {
on: {
VALUE: "validating"
}
},
submitting: {
...
}
}
}
Form state is not a data anymore
SUBMIT only happens when validating is done and inputs are valid
<Carousel items={[]} />
event: Prev
event: Next
States!
State: First
State: Middle
State: Last
{
initial: "first",
cursor: 1,
states: {
first: {
on: {
NEXT: {
target: "middle",
actions: "incrementCursor"
}
}
},
middle: {
on: {
NEXT: [
{target: "last", cond: "isNextItemTheLastItem", actions: "incrementCursor"},
{target: "middle", actions: "incrementCursor"}
],
PREV: [
{target: "first", cond: "isPrevItemTheFirstItem", actions: "decrementCursor"},
{target: "middle", actions: "decrementCursor"}
]
}
},
last: {
on: {
PREV: {
target: "middle",
actions: "decrementCursor"
}
}
}
}
}
Start off form the first item
State: First
State: Middle
State: Last
<Carousel items={[]} startIndex={2} />
{
initial: startIndex === 1 ? "first" : startIndex === items.length ? "last" : "middle",
cursor: startIndex,
states: {
first: {
on: {
NEXT: {
target: "middle",
actions: "incrementCursor"
}
}
},
middle: {
on: {
NEXT: [
{target: "last", cond: "isNextItemTheLastItem", actions: "incrementCursor"},
{target: "middle", actions: "incrementCursor"}
],
PREV: [
{target: "first", cond: "isPrevItemTheFirstItem", actions: "decrementCursor"},
{target: "middle", actions: "decrementCursor"}
]
}
},
last: {
on: {
PREV: {
target: "middle",
actions: "decrementCursor"
}
}
}
}
}
State: First
State: Middle
State: Last
getFirstState(startIndex)
<Carousel items={[]} startIndex={1} />
<Carousel items={[]} startIndex={2} />
<Carousel items={[]} startIndex={3} />
<Carousel items={[]} startIndex={2} cyclic />
{
first: {
on: {
NEXT: {
target: "middle",
actions: "incrementCursor"
},
PREV: {
target: "last",
cond: "isCyclic",
actions: "setCursorToLast"
}
}
},
middle: {
...middle
},
last: {
on: {
NEXT: {
target: "first",
cond: "isCyclic",
actions: "setCursorToFirst"
},
PREV: {
target: "middle",
actions: "decrementCursor"
}
}
}
};
State: First
State: Middle
State: Last
<Carousel items={[]} startIndex={2} cyclic dir="ltr" />
Next
(LTR)
Prev
(RTL)
Cyclic ❌
Cyclic ✅
{
first: {
on: {
NEXT: [
{target: "last", cond: "isCyclic & isRtl", actions: "setCursorToLast"},
{
target: "middle",
actions: "incrementCursor"
}
],
PREV: [
{target: "middle", cond: "isRtl", actions: "incrementCursor"},
{
target: "last",
cond: "isCyclic",
actions: "setCursorToLast"
}
]
}
},
...
};
State: First
State: Middle
State: Last
<Carousel
items={[]} startIndex={2} cyclic dir="rtl"
autoplay={2000}
/>
{
first: {
entry: send({type: "NEXT"}, {delay: "AUTOPLAY", id: "autoPlayEvent"}),
exit: cancel("autoPlayEvent"),
on: {
NEXT: [
{target: "last", cond: "isCyclic & isRtl", actions: "setCursorToLast"},
{
target: "middle",
actions: "incrementCursor"
}
],
PREV: [
{target: "middle", cond: "isRtl", actions: "incrementCursor"},
{
target: "last",
cond: "isCyclic",
actions: "setCursorToLast"
}
]
}
},
...
};
State: First
State: Middle
State: Last
That's it
carouselMachine.withConfig({
delays: {
AUTOPLAY: props.autoplay
}
})
It's here
<Carousel items={[]} startIndex={2} cyclic />
{
paused: { id: "paused", on: { PLAY: "#playing"} },
playing: {
first: {
after: {
AUTOPLAY: {
actions: send("NEXT"),
},
},
on: {
PAUSE: "#paused",
...
},
},
...
},
};
State: First
State: Middle
State: Last
Pause with state
Gets back to Playing
{
paused: { id: "paused", on: { PLAY: "#playing.hist"} },
playing: {
hist: {
type: "history",
history: "deep"
},
first: {
after: {
AUTOPLAY: {
actions: send("NEXT"),
},
},
on: {
PAUSE: "#paused",
...
},
},
...
},
};
State: First
State: Middle
State: Last
History States!
{
paused: {},
playing: {
transitioning: {
after: {
TRANSITION_DELAY: "#waiting.hist"
}
},
waiting: {
hist: {},
first: {},
middle: {},
last: {},
},
},
};
function HeadlessCarousel(props) {
const {children, ...carouselProps} = props;
const [state, sendEvent] = useMachine(carouselProps);
return children({
state: state.value,
data: state.context,
next() {
sendEvent("NEXT")
},
prev() {
sendEvent("PREV")
},
play() {
sendEvent("PLAY");
},
pause() {
sendEvent("PAUSE")
}
})
}
<HeadlessCarousel>
{headlessCarousel => {
<div>
<button onClick={headlessCarousel.next}>
Next
</button>
</div>
}}
</HeadlessCarousel>
<HeadlessCarousel>
{headlessCarousel => {
<View>
<TouchableOpacity onPress={headlessCarousel.next}>
Next
</TouchableOpacity>
</View>
}}
</HeadlessCarousel>
stdin.on("keypress", (_, code) => {
if (code === ARROW_RIGHT) {
sendEvent("NEXT");
}
});
Thank You!
Grazie!
Farzad Yousef Zadeh
@farzad_yz