The art of

explicit and consistent user interfaces

Farzad Yousef Zadeh

Senior software engineer 

Former Aerospace engineer 🚀 and astrophysicist 🌌

Implicit vs Explicit state

Finite state vs Infinite state

  • Decoupled from implementation

  • ported to several platforms

logic

  • Modeled

We will talk about

  • Visualized

The What

Scene #1

renderer (state) = view

Component library / Framework

State management tool
Tool specific approaches

Local state

Global state

store.dispatch({
  type: "UPDATE_TITLE",
  payload: "Holyjs"
})
@action onClick() {
  this.props.title = "HolyJS"
}
const [title, setTitle] = useState("")

setTitle("HolyJS")
this.state = {
  title: "";
}

this.setState({title: "HolyJS"})
new Vuex.Store({
  state: {
    title: ""
  },
  mutations: {
    updateTitle: (state, payload) {
      state.title = payload.title
    }
  }
})

One purpose

multiple approaches

State management tools

are containers

deal with the architecture                         (flux, push based, pull based)

tell you how to store states                         (single source, distributed)

tell you how to update states             (Mutable, immutable, observable)

Current state flow

After adding modeling layer

A flashback to

GUI history

GUI is event-driven

Alan Kay, the Dynabook, 1972

GUI is built upon events and messages

Listen to events and execute actions (side effects)

event + action paradigm

The problem

Scene #2

facetime bug

MS calculator bug

Event handler's logic based on previously happened events

Press the (-) button

Context aware event handlers

Constructing the User Interface with Statecharts

Ian Horrocks 1999

What was wrong with those then?

Implicitly handling the state

Event handlers

Mutations

Side effects

Asynchrony

button.addEventListener("click", ...)
this.setState({ ... })
new Promise(...)
window.fetch(...)
if (typeof index === "boolean") {
  addBefore = index;
  index = null;
} else if (index < 0 || index >= _.slideCount) {
  return false;
}

_.unload();

if (typeof index === "number") {
  if (index === 0 && _.$slides.length === 0) {
    $(markup).appendTo(_.$slideTrack);
  } else if (addBefore) {
    $(markup).insertBefore(_.$slides.eq(index));
  } else {
    $(markup).insertAfter(_.$slides.eq(index));
  }
} else {
  if (addBefore === true) {
    $(markup).prependTo(_.$slideTrack);
  } else {
    $(markup).appendTo(_.$slideTrack);
  }
}
{
  value: "",
  valid: false
}

No value

Invalid

no validation error

A single input with IMPLICIT state

{
  value: "fars",
  valid: false
}

with value

Invalid

validation error shown

A single input with IMPLICIT state

{
  value: "",
  valid: false
}

No value

Invalid

validation error shown

A single input with IMPLICIT state

{
  value: "",
  valid: false
}
{
  value: "",
  valid: false
}

uh Oh!

{
  value: "",
  valid: false,
  isValidated: false
}

No value

Invalid

no validation error

A single input with IMPLICIT state

{
  value: "fars",
  valid: false,
  isValidated: true
}

with value

Invalid

validation error shown

A single input with IMPLICIT state

{
  value: "",
  valid: false,
  isValidated: true
}

No value

Invalid

validation error shown

A single input with IMPLICIT state

{
  value: "farskid@gmail.com",
  valid: true,
  isValidated: true
}

with value

valid

no validation error

A single input with IMPLICIT state

value.length  = 0  > 0
valid true false
isValidated true false
value.length = 0, valid: false, isValidated: false
value.length = 0, valid: false, isValidated: true
value.length > 0, valid: false, isValidated: true
value.length > 0, valid: true, isValidated: true
value.length  = 0  > 0
valid true false
isValidated true false
value.length = 0, valid: true, isValidated: false
value.length = 0, valid: true, isValidated: true
value.length > 0, valid: false, isValidated: false
value.length > 0, valid: true, isValidated: false

2

2

2

4

valid states:

4

impossible states:

impossible states

v.length
valid
isValidated
2^3

with bad modeling,

your code complexity grows faster than the domain complexity

with impossible states,

You need to test cases that won't event happen in real life

An impossible state is where you tell users to restart

Add guards to avoid impossible states

function handleChange(newValue) {
  if (!isValidated) {
    setValidated(true);
  }

  if (newValue.length === 0 || !validateEmail(newValue)) {
    setValid(false);
  } else {
    setValid(true);
  }

  setValue(newValue);
}

Guards increase

cyclomatic complexity

Number of independent paths a program can take 

More guards: 

HIGHER CYCLOMATIC COMPLEXITY

LESS PREDICTABLE LOGIC

if, else, 
while, for, 
switch, case

Harder to track logic


if (typeof index === "boolean") {
  addBefore = index;
  index = null;
} else if (index < 0 || index >= _.slideCount) {
  return false;
}

_.unload();

if (typeof index === "number") {
  if (index === 0 && _.$slides.length === 0) {
    $(markup).appendTo(_.$slideTrack);
  } else if (addBefore) {
    $(markup).insertBefore(_.$slides.eq(index));
  } else {
    $(markup).insertAfter(_.$slides.eq(index));
  }
} else {
  if (addBefore === true) {
    $(markup).prependTo(_.$slideTrack);
  } else {
    $(markup).appendTo(_.$slideTrack);
  }
}
{
  inputs: {
    email: {
      value: "",
      valid: false,
      isValidated: false
    }
  },
  submitting: false,
  submitError: undefined
}

embed input in form

{
  inputs: {
    email: {
      value: "fars",
      valid: false,
      isValidated: true
    }
  },
  submitting: false,
  submitError: undefined
}

embed input in form

{
  inputs: {
    email: {
      value: "",
      valid: false,
      isValidated: true
    }
  },
  submitting: false,
  submitError: undefined
}

embed input in form

{
  ...
  submitting: true,
  submitError: undefined
}
{
  inputs: {
    email: {
      value: "farskid@gmail.com",
      valid: true,
      isValidated: true,
    }
  },
  submitting: false,
  submitError: "Incorrect password."
}
{
  submitting: boolean;
  submitError: string | undefined;
}

Impossible states again!

submitting: false, error: undefined
submitting: true, error: undefined
submitting: false, error: "Error"
submitting: false, error: undefined

Editing

Submitting

Failed

Succeeded

{
  submitting: boolean;
  submitError: string | undefined;
  isSuccess: boolean;
}

Same state object, different views

Adding Guards

function handleSubmit(e) {
  e.preventDefault();
  
  const canSubmit = Object.values(state.inputs)
    .map(v => v.valid)
    .every(v => v === true);
  
  if (!canSubmit) {
    return;
  }
}

Avoid mutually exclusive behaviors

from happening simultaneously

Avoid transitioning to the impossible states

The Solution

Scene #3

Discovering Finite states

Thinking about states explicitly

Think in states explicitly

in the input example

Empty

Finite state

inFinite state

{value: ""}

Invalid

{value: "fars"}

Invalid

{value: ""}
{value: "farskid@gmail.com"}

Valid

Type

InputState = "Empty" | "Invalid" | "Valid"
function transition(state, event) {
  switch(state) {
    case "Empty":
    case "Invalid":
    case "Valid":
      switch(event.type) {
        case "TYPE":
          return validateEmail(event.payload) 
            ? "Valid" : "Invalid"
        default:
          return state;
      }
    default:
      throw Error("Impossible state")
  }
}
onChange(e) {
  setInputContext(e.target.value)
  setInputState(transition(inputState, "TYPE"))
}
<input
  type="text"
  onChange={e => {
    setInputContext(e.target.value);
    setInputState(
      transition(inputState, "TYPE")
    );
  }}
/>;

{
  inputState === "Invalid" && 
  <p>Enter a valid email address</p>;
}

conditional rendering based on finite state

{
  !valid && isValidated && 
  <p>Enter a valid email address</p>;
}

Think in states explicitly in the form example

Finite state

inFinite state

type State =
  | {
      formState: 
        | "Valid" | "Submitting"
        | "SubmitSucceeded" | "SubmitFailed"
      InputStates: {
        email: "Valid"
        password: "Valid"
      };
    }
  | {
      formState: "Invalid";
      inputStates: {
        email: "Empty" | "Invalid" | "Valid"
        password: "Empty" | "Invalid" | "Valid"
      };
    };

type FormContext = {
  email: string
  password: string
  submitError:
    | Error
    | undefined
}
<button
  type="submit"
  disabled={
    state.formState === "Submitting"
}>
  Sign In
</button>

{
  formState.state === "SubmitFailed" && 
  <p>{ formContext.submitError }</p>;
}

conditional rendering based on finite state

Tooltip/modal/dropdown

type State = "Opened" | "Closed"

Button

type State = 
| "Normal"
| "Hovered"
| "Active.Idle"
| "Active.Focused"
| "Disabled"

Range Input / Progress

type State = 
| "Min"
| "Mid"
| "Max"

const context = {
  min: number,
  max: number,
  value: number
}

Elements have finite states

type Promise =
| Pending
| Settled.Fulfilled
| Settled.Rejected

const Context = {
  resolvedValue?: any;
  rejectedError?: any
}

Time based side effects have finite states

Scaling

Scene #4

Making modeling practical

Define states explicitly

Separate finite and infinite states

abstract with focus on logic

Reduce guards and cyclomatic complexity

capable of modeling complex GUI

statecharts

David HAREL (1987):

A VISUAL FORMALISM FOR COMPLEX SYSTEMS* 

Extends FSM model

States with relations

Several places to run side effects

Several types of side effects

state + event => 

next state + side effects

Statechart implementation library

Xstate

Based on SCXML

JSON definition

Built-in Visualizor

Rewrite the input

in statecharts

Interactions in statecharts

modeling a Dragging box

Interactions with statecharts

Released
GRAB
Grabbed
MOVE
Dragging
MOVE

shiftX

shiftY

PageY

pageX

RELEASE
onMouseDown = () => {
  sendEvent({ type: "GRAB", data: { shiftX, shiftY } });
}

onMouseMove = () => {
  sendEvent({
    type: "MOVE",
    data: { x: event.pageX, y: event.pageY }
  });
}

onMouseUp = () => {
  sendEvent("RELEASE");
};

Event listeners and statecharts

box.onmousedown = function(event) {
  // (1) prepare to moving: make absolute and on top by z-index
  box.style.position = 'absolute';
  box.style.zIndex = 1000;
  // move it out of any current parents directly into body
  // to make it positioned relative to the body
  document.body.append(box);
  // ...and put that absolutely positioned ball under the pointer

  moveAt(event.pageX, event.pageY);

  // centers the ball at (pageX, pageY) coordinates
  function moveAt(pageX, pageY) {
    box.style.left = pageX - box.shiftX + 'px';
    box.style.top = pageY - box.shiftY + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) move the ball on mousemove
  document.addEventListener('mousemove', onMouseMove);

  // (3) drop the ball, remove unneeded handlers
  box.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    box.onmouseup = null;
  };
};
{
  initial: "released",
  context: {
    shiftX: 0,
    shiftY: 0,
    pageX: 0,
    pageY: 0
  },
  states: {
    released: {
      on: {
        GRAB: {
          target: "grabbed"
        }
      }
    },
    grabbed: {
      entry: [
        "saveShiftPoints",
        "saveBoxPositions",
        "prepareBoxStyles",
        "moveBox"
      ],
      on: {
        MOVE: "dragging"
      }
    },
    dragging: {
      entry: [
        "saveBoxPositions",
        "moveBox"
      ],
      on: {
        MOVE: "dragging",
        RELEASE: "released"
      }
    }
  }
}

Before

AFTER

Additional benefits

Scene #5

{
  initial: "released",
  context: {
    shiftX: 0,
    shiftY: 0,
    pageX: 0,
    pageY: 0
  },
  states: {
    released: {
      on: {
        GRAB: {
          target: "grabbed"
        }
      }
    },
    grabbed: {
      entry: [
        "saveShiftPoints",
        "saveBoxPositions",
        "prepareBoxStyles",
        "moveBox"
      ],
      on: {
        MOVE: "dragging"
      }
    },
    dragging: {
      entry: [
        "saveBoxPositions",
        "moveBox"
      ],
      on: {
        MOVE: "dragging",
        RELEASE: "released"
      }
    }
  }
}

Box is released at first

When it's released, it can be grabbed

As soon as it's grabbed, we remember mouse position and box position, prepare its styles and move it.

When it's grabbed, it can move

As soon as it's moving, we update its current position and move it.

When it's moving, it can be released

As long as it's moving, we keep moving it which means continuously updating its position.

Statecharts read like English

Statecharts visualize logic

Generate directed Graph

Showcase state paths

Paths can be used by QA team to test for edge cases

cross competence teams

onboarding

Statecharts visualization in pull requests

Statecharts decouple logic from implementation

Abstract declarative JSON

{
  initial: "A",
  states: {
    A: {},
    B: {}
  }
}

Implementation

statechart.withConfig({
  actions: {},
  services: {},
  delay: {}
})

PLatform api

Next time someone asked how hard can it be?

Answer: let's draw its statecharts and see!

RECAP

Scene #6

levels solution complexity and problem complexity

Recap

finite state vs infinite state

statecharts for practical modeling complex applications

statecharts for knowledge sharing and communication

Implicit vs explicit state management

A new modeling layer

make impossible states, impossible

Avoid mutual exclusivity problems

THINK in STATES

Check these out

The World of statecharts

xstate
@xstate/react
@xstate/test
@xstate/graph
@xstate/fsm

Thank you!

Спасибо!

 

Slides at:

HolyJS Moscow 2019