Tackle React component Complexity
using
Reactive StateCharts

 

Farzad Yousef Zadeh

@farzad_yz

Farzad Yousef Zadeh

✈ Aerospace engineer
🔭 Astrophysicist 
Senior Software Engineer @
CurrentState
PreviousState
github.com/farskid
twitter.com/farzad_yz

🇫🇮

People have different definitions
for
Complexity

Complexity

A11y/U9y

External Actors

Styling

Cross Platform Sharing

Cross Browser Compatibility

Business Logic

Taking many parameters into account

Branching

Cyclomatic Complexity

Parameters

Cyclomatic Complexity

Cyclomatic
Complexity

Confusion

?

?

?

?

?

Confusion

Unpredictable
Behaviour

Complexity plays well with

Legacy Code

The Time

Writing the Software is EASY

Maintaining it is HARD

Let's talk about Behaviour

What is Behaviour really?

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.

Behaviour
is
Abstract

Behaviour

Impl Specific

( Part of )

An Abstract Language

to describe

Behaviour

StateCharts

extends

Finite State Machines

quintuple

WTF?

State

State

Initial State

Event

Event

Event

Event

f(state, event) => state

Finite state Machine

What is StateChart then?

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

What is StateChart then?

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

Think in STATES

State ≠ State

type State = {
  isLoading: boolean;
  accounts: Account[];
  error?: any;
};
type State1 = 
| "Idle"
| "Loading"
| "Success"
| "Error";

We're used to

in the FSM world

React Component

State Machine

Your State management tool
(Hooks, Redux, Mobx, RXJS, ...)

1

2

3

4

Time to get Practical

We will use the XSTATE library for our examples

We will use the JSON format to define the behaviour

Serializable

No surprise

Be Aware

👏🎹 @davidkpiano

we will focus on Component level state.

Debouncing input

Example 1

<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)

Debouncing input

Cancellation

Example 2

<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

Noticed what happened to VIEW?

Cancellation

Mutual Exclusivity

Example 3

<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 ???

Impossible state

{
  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

Avoid Mutual exclusivity

Underestimating the complexity growth

Example 4

<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

Custom Start Index

Feature Request

<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} />

Cyclic Carousels

Feature Request

<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

Directions (LTR & RTL)

Feature Request

<Carousel items={[]} startIndex={2} cyclic dir="ltr" />

First State

Next

(LTR)

Prev

(RTL)

Cyclic ❌

Cyclic ✅

Combinatorial Explosion

Rapid growth of a problem based on how the combinators of the problem are affected by the input

{
  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

Autoplay

Feature Request

<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 

Pause and Resume

Feature Request

<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

But it Resets!

{
  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!

Animation!

What We missed

{
  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");
  }
});

Web

Mobile

Command Line 😱

State Machines
are

Implementation Details

End user doesn't care if you're using state machines

End User has a more flattened perception
of
Your App

Use state machines to model

Let this model be used by your choice of tech

Test like you don't have state machines

Use this model as a means of communication

Thank You!
 

Grazie!

 

Farzad Yousef Zadeh

@farzad_yz