Hooks

What are hooks?

Hooks are the recommended way for the developer to provide or read state into their components.

By convention, the name of a hook will start with a use prefix to denote their hook status.

The example below displays the brightness hardware variable as a percentage, and also colors the background based on the current percentage.

import { useHardwareState } from '@electricui/components-core'
const ComponentThatTakesState = () => {
const brightnessPercentage = useHardwareState('brightness')
let color = 'white'
if (brightnessPercentage > 50) {
color = 'red'
} else if (brightnessPercentage < 20) {
color = 'blue'
}
return (
<div style={{ background: color, padding: 20 }}>
LED Brightness: {brightnessPercentage}%
</div>
)
}

While it may look like the useHardwareState function call merely grabs the current hardware state, a bit more is happening under the hood.

The hook understands the device context it resides in, automatically fetching the state of the correct device.

Each call to a hook is registered by the renderer in order. In the event the brightness changes, this component will automatically re-render. If the state doesn't change, the component won't re-render.

There are several rules and a few strong recommendations regarding the usage of hooks. For a technical explanation of why, skip to the end.

Only call hooks at the top level.

Don't call hooks inside loops, conditionals or nested functions. Call the same hooks in the same order every render.

Hook calls are recorded and maintained in order, as a result, their order must be maintained.

This is not allowed:

const ComponentThatTakesState = () => {
const ledError = useHardwareState('led_error_code')
if (ledError) {
return <div>Failed to grab LED color.</div>
}
const brightnessPercentage = useHardwareState('brightness')
let color
if (brightnessPercentage > 0) {
color = useHardwareState('color')
}
return (
<div>
LED Brightness: {brightnessPercentage}%
{brightnessPercentage > 0 ? <> LED Color: {color}</> : null}
</div>
)
}

If led_error_code changes, the amount of hooks rendered per render will change.

If brightness switches between 0 and a value above 0, again the amount of hooks rendered per render will change.

Instead, call the hooks at the top level, calling the same amount each render.

const ComponentThatTakesState = () => {
const ledError = useHardwareState('led_error_code')
const brightnessPercentage = useHardwareState('brightness')
const color = useHardwareState('color')
if (ledError) {
return <div>Failed to grab LED color.</div>
}
return (
<div>
LED Brightness: {brightnessPercentage}%
{brightnessPercentage > 0 ? <> LED Color: {color}</> : null}
</div>
)
}

Only call hooks within function based components

Hooks rely on information stored within the React render tree, calling them outside of this tree will result in an error.

This is not allowed:

const brightnessDataSource = useMessageDataSource('brightness')
const averageBrightness = useDataTransformer(
brightnessDataSource,
() => ({
queue: new FixedQueue<Event<number>>(100), // store up to the last 100 brightness events
}),
(event, dataSource, state, emit) => {
// Add the brightness event to the queue
state.queue.push(event)
// NOT ALLOWED, this isn't a component in the React render tree
const averagingTimeWindow = useHardwareState('b_time_window') ?? 2000
let total = 0
let counter = 0
const queueArray = state.queue.copy()
const now = timing.now()
for (let index = 0; index < queueArray.length; index++) {
// Only use values from the past `averagingTimeWindow` of time
if (event.time < now - averagingTimeWindow) {
continue
}
total += queueArray[index].data // add the brightness value
counter += 1
}
if (counter === 0) {
return
}
const average = total / counter
// Emit our averaged event
emit(new Event(event.time, average))
},
)

In most cases, there will be an API dedicated to providing the state you need outside of the render tree.

For example, the above example is idiomatically demonstrated by moving the averagingTimeWindow state into the DataTransformer itself, then mutating it from the React render tree.

const brightnessDataSource = useMessageDataSource('brightness')
const averageBrightness = useDataTransformer(
brightnessDataSource,
() => ({
queue: new FixedQueue<Event<number>>(100), // store up to the last 100 brightness events
averagingTimeWindow: 0, // Initially the averaging time window is 0ms
}),
(event, dataSource, state, emit) => {
// Add the brightness event to the queue
state.queue.push(event)
let total = 0
let counter = 0
const queueArray = state.queue.copy()
const now = timing.now()
for (let index = 0; index < queueArray.length; index++) {
// Only use values from the past `averagingTimeWindow` of time
if (event.time < now - state.averagingTimeWindow) {
continue
}
total += queueArray[index].data // add the brightness value
counter += 1
}
if (counter === 0) {
return
}
const average = total / counter
// Emit our averaged event
emit(new Event(event.time, average))
},
)

Then within a React component:

function AverageBrightnessChart() {
// The useDataSubscription hook returns functions that can be called without the hook rules.
const { getDataSourceFromReference } = useDataSubscription()
// Subscribe to the hardware state, if `b_time_window` ever changes while this component is mounted,
// update the data source of _this device_.
useHardwareStateSubscription('b_time_window', (value: number) => {
// Get _this device's_ data source. We can strictly type it to get autocomplete on
// the state with the 'as' type annotation.
const ourDataSource = getDataSourceFromReference(
averageBrightness,
) as typeof averageBrightness
// Set the mutable state of our version of the data transformer to the value from hardware.
ourDataSource.state.averagingTimeWindow = value
})
// The rest is a regular chart
return (
<>
<div style={{ textAlign: 'center', marginBottom: '1em' }}>
<b>Average brightness</b>
</div>
<ChartContainer height={400}>
<LineChart
dataSource={averageBrightness}
lineWidth={2}
/>
<RealTimeDomain window={30_000} />
<TimeAxis />
<VerticalAxis />
</ChartContainer>
</>
)
}

Here a hardware state subscription is setup. When the b_time_window messageID is updated, the averageBrightness Data Transformer is grabbed. The useDataSubscription hook makes sure the current device context is included, so the provided ourDataSource return is the correct version of the data source for the device on screen.

The as typeof averageBrightness type annotation gives us autocomplete on the state property of the Data Transformer.

The Data Transformers internal state can then be mutated, passing the value of the b_time_window messageID into the averagingTimeWindow state key.

Grab state as late as possible, as deep as possible

While not a rule, it is heavily encouraged to use hooks as deep in the component tree as is reasonable.

It is tempting to have several useHardwareState hooks on the main page of your user interface, and while this will function, each state update will cause a re-render for the entire page component, redoing unnecessary work.

By breaking the user interface up into smaller components that update independently, updates can be smaller and therefore faster.

Don't do this if at all possible:

const Page = () => {
const state1 = useHardwareState('state1')
const state2 = useHardwareState('state2')
// ...
const state42 = useHardwareState('state42')
return <>{/* Very large page with lots of things */}</>
}

Instead break the page up into sub components that grab only the state they need:

const SubComponent1 = () => {
const stateRequired = useHardwareState('state1')
return <>stateRequired is {stateRequired}</>
}
const SubComponent2 = () => {
const stateRequired = useHardwareState('state2')
return <>stateRequired 2 is {stateRequired}</>
}
const Page = () => {
return (
<>
<SubComponent1 />
<SubComponent2 />
</>
)
}

Using hooks

Simply import them, then use them in your components.

import { useHardwareState } from '@electricui/components-core'
const ComponentThatTakesState = () => {
const brightnessPercentage = useHardwareState('brightness')
return <div>LED Brightness: {brightness}%</div>
}

Publicly available hooks and corresponding documentation are in the hooks API subsection of the docs:

Behind the scenes

All of Electric UIs hooks are composed of primatives that either hold state (the React useState hook), or cause effects (the React useEffect) hook. (There are others, but they are an implementation detail.)

A brief foray into React's API surface, the useState hook takes a default value and returns a tuple of the current state, and a setter function.

const [state, setState] = useState(false)
setState(true)
// state = true
setState(false)
// state = false

Components can make multiple calls to each hook, so how does React know which call is which in our code?

function Component() {
const [state1, setState1] = useState(false) // call A
const [state2, setState2] = useState(false) // call B
return (
<div>
State 1: {state1}
State 2: {state2}
</div>
)
}

The defaults can be the same and the useState function can't know that the tuple is destructured into state1 and state2 variables.

The answer relies on ordering.

When the component is rendered, a counter is set to 0, each hook call increments that counter and whatever state is necessary is allocated into a 'cell' that persists with the instance of this component within the render tree.

As long as the component calls the same number of hook functions in the same order each time, the cells will match up with what's intended.

This breaks down if there is a branch, hence the first rule of hooks, to only call them at the 'top level'.

function Component() {
const [state1, setState1] = useState(false) // call A
if (state1 === true) {
const [state2, setState2] = useState('hello') // call B
return (
<div>
State 1: {state1}
State 2: {state2}
</div>
)
}
const [state3, setState3] = useState(123) // call C
return <div>State 2: {state2}</div>
}

When the component renders the first time, call B will be skipped, the cell table will look like:

call 1: false
call 2: 123

If state1 is set to true, the branch will be evaluated, and call B will be made in the second slot.

call 1: false
call 2: 123
call 3: ???

Call B will be provided with 'its' state, which according to the cells is 123, which is incorrect. When Call C occurs, React knows something went wrong, since a different number of hooks ran this time.