Skip to content

React Native

Terminal window
npm install @dunky.dev/native-state-machine

The React Native package is the same thin edge layer as React (same lifecycle hook, same ComponentEffect model) with one difference: normalize translates bindings to React Native props instead of DOM/ARIA props. The machine itself is unchanged. The same config and connect function run on both platforms.

Re-exported directly from @dunky.dev/react-state-machine, identical on both platforms because React Native uses the same React renderer. See the React page for full docs on these.

import { useMachine, useSelector, type ComponentEffect } from '@dunky.dev/native-state-machine'

normalize: bindings → React Native props

Section titled “normalize: bindings → React Native props”

The RN normalize translates the same agnostic bindings to React Native’s prop vocabulary:

import { normalize } from '@dunky.dev/native-state-machine'
;<Pressable {...normalize(api.triggerProps)} />

The machine binding maps handlers (onPressonPress, onPointerDownonPressIn), accessibility props (labelledByaccessibilityLabelledBy, roleaccessibilityRole, idnativeID), and accessibility state (expandedaccessibilityState.expanded). Check out the full mapping here.

Bindings with no RN equivalent (hover, onKeyDown, onWheel) are silently dropped rather than passed as invalid props.

RN-aware merge: handlers are chained, style is merged as a [consumerStyle, libraryStyle] array (RN accepts style arrays natively), everything else the component wins.

import { mergeProps, normalize } from '@dunky.dev/native-state-machine'
;<Pressable {...mergeProps(props, normalize(api.triggerProps))} />

Platform effects work the same way: a [setup/teardown, depPropNames] tuple passed to useMachine. The platform API changes, not the shape:

import { BackHandler } from 'react-native'
import type { ComponentEffect } from '@dunky.dev/native-state-machine'
import type { DialogMachine, DialogProps } from './machine'
type Effect = ComponentEffect<DialogMachine, DialogProps>
const onBackButton: Effect = [
(machine, props) => {
if (!props.closeOnBackButton) return
const handler = BackHandler.addEventListener('hardwareBackPress', () => {
machine.send({ type: 'close' })
return true // prevent default back navigation
})
return () => handler.remove()
},
['closeOnBackButton'],
]
export const dialogEffects = [onBackButton]

The machine receives the same send({ type: 'close' }) as the web version. The machine is unchanged; only the transport differs.

The machine config and connect function are written once and shared across platforms:

// shared/disclosure.ts: imported by both web and native components
export const createDisclosureConfig = (props: DisclosureProps) => ({ ... })
export const connectDisclosure = ({ state, send }) => ({ ... })
Disclosure.tsx
import { useMachine, normalize } from '@dunky.dev/react-state-machine'
import { createDisclosureConfig, connectDisclosure } from '../shared/disclosure'
import { disclosureEffects } from './effects' // DOM keydown listener
// native: Disclosure.native.tsx
import { useMachine, normalize } from '@dunky.dev/native-state-machine'
import { createDisclosureConfig, connectDisclosure } from '../shared/disclosure'
import { disclosureEffects } from './effects' // BackHandler

The machine never changes. Only normalize and the ComponentEffect list differ between targets.