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 = truesetState(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: falsecall 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: falsecall 2: 123call 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.