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)} />
BindingReact Native prop
onPressonPress
onPointerDownonPressIn
onFocus / onBluronFocus / onBlur
onPointerEnter/Leave/Movedropped — no RN analog; use focus or long-press instead
onKeyDowndropped — no RN analog
describedBy / labelledByaccessibilityLabelledBy
roleaccessibilityRole
idnativeID
expanded / selected / disabled / hiddenaccessibilityState.expanded/selected/disabled/hidden
focusablefocusable

Bindings with no RN equivalent 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 }) => ({ ... })
// web — 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.