Skip to main content

Getting Started

This guide walks you through creating your first ESPHome Compose project — writing ESP device configurations in TypeScript/TSX instead of YAML.

For a complete working example, see the espcompose-demo repository.

Prerequisites

  • Node.js 22+
  • ESPHome (only needed for config, build, run, and logs commands)

Creating a Project

Use the init command to scaffold a new project:

npx @espcompose/cli init my-device

This creates a my-device/ directory with:

FilePurpose
index.tsxDevice configuration entry point
package.jsonDependencies (@espcompose/core, CLI, ESLint plugin)
tsconfig.jsonTypeScript config extending @espcompose/core/tsconfig.sdk.json
eslint.config.mjsESLint config with ESPHome Compose rules
.gitignoreIgnores node_modules/, .espcompose/, .espcompose-build/, and dist/

You can optionally specify a board (defaults to esp32dev):

npx @espcompose/cli init my-device --board esp32-s3-devkitc-1

Then install dependencies and transpile:

cd my-device
npm install
npx espcompose transpile

The generated YAML is written to .espcompose/esphome.yaml.

The Entry File

The scaffolded index.tsx is a minimal device configuration:

import { secret } from '@espcompose/core';

export default (
<esphome name="my-device" comment="An ESPHome Compose device">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api />
<ota platform="esphome" />
<logger level="DEBUG" />
</esphome>
);

The default export is a JSX element tree that the compiler uses as the entry point. Each intrinsic element (<esphome>, <esp32>, <wifi>, etc.) maps directly to an ESPHome YAML section. Props are written in camelCase and automatically converted to snake_case in the output.

Function Components

Extract reusable pieces into function components:

import { secret } from '@espcompose/core';

function CoreInfrastructure() {
return (
<>
<api />
<ota platform="esphome" />
<logger level="INFO" />
</>
);
}

export default (
<esphome name="my-device">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<CoreInfrastructure />
</esphome>
);

Fragments (<>...</>) let a component return multiple sibling elements without a wrapper.

Refs — Typed Cross-Component References

ESPHome uses string IDs to link components together (e.g. a light referencing an output). ESPHome Compose replaces manual ID strings with typed refs.

Call useRef<T>() to create a typed reference, pass it to an element's ref prop to register it, and use it in other elements to reference it:

import { secret, useRef } from '@espcompose/core';
import type { FloatOutputRef, LightOutputRef } from '@espcompose/core';

const outputRef = useRef<FloatOutputRef>();
const lightRef = useRef<LightOutputRef>();

export default (
<esphome name="my-device">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api />
<logger level="DEBUG" />
<output platform="ledc" ref={outputRef} pin={19} frequency="1000Hz" />
<light ref={lightRef} platform="monochromatic" name="Desk Light" output={outputRef} />
</esphome>
);

The compiler generates unique IDs and wires them in the YAML output:

output:
id: r_a1b2c3d4e
platform: ledc
pin: 19
frequency: 1000Hz
light:
id: r_f5g6h7i8j
platform: monochromatic
name: Desk Light
output: r_a1b2c3d4e

The type parameter (e.g. FloatOutputRef) provides type safety — your IDE will error if you pass a ref of the wrong type to a prop that expects a different component kind.

Event Handlers and Scripts

Inline Event Handlers

Trigger props (props starting with on) accept async arrow functions. The function body is compiled into an ESPHome action list:

import { delay } from '@espcompose/core';

<binary_sensor
platform="gpio"
pin={4}
name="Button"
onRelease={async () => {
await delay(100);
}}
/>

This compiles to:

binary_sensor:
platform: gpio
pin: 4
name: Button
on_release:
- delay: 100ms

Named Scripts

Use useScript() to create reusable ESPHome script: components. The async arrow function body is compiled at the AST level — it is never executed at runtime. Must be called inside a function component body:

import { delay, logger, secret, useScript } from '@espcompose/core';

function App() {
const greet = useScript(async () => {
logger.log('Hello from ESPCompose!');
await delay(500);
});

return (
<esphome name="my-device">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api />
<logger level="INFO" />
<binary_sensor
platform="gpio"
pin={4}
name="Button"
onPress={async () => { await greet(); }}
/>
</esphome>
);
}

export default <App />;

Action Primitives

Import action primitives from @espcompose/core to use inside script bodies and trigger handlers:

FunctionYAML Output
await delay(ms)delay: <ms>ms
await delay('1s')delay: 1s
logger.log(message, level?)logger.log: { message, level }

Ref Actions

Refs to actionable components (lights, switches, etc.) provide typed action methods. Inside useScript() or trigger handlers, calling these methods emits the corresponding ESPHome actions:

import { delay, secret, useRef, useScript } from '@espcompose/core';
import type { LightOutputRef, SwitchRef, FloatOutputRef } from '@espcompose/core';

function App() {
const lightRef = useRef<LightOutputRef>();
const switchRef = useRef<SwitchRef>();
const outputRef = useRef<FloatOutputRef>();

const toggleAll = useScript(async () => {
lightRef.toggle();
await delay(200);
switchRef.toggle();
});

return (
<esphome name="my-device">
<esp32 board="esp32dev" framework={{ type: 'esp-idf' }} />
<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api />
<logger level="DEBUG" />
<output platform="ledc" ref={outputRef} pin={19} frequency="1000Hz" />
<light ref={lightRef} platform="monochromatic" name="Desk Light" output={outputRef} />
<switch ref={switchRef} platform="gpio" pin={5} name="Relay" />
<binary_sensor
platform="gpio"
pin={4}
name="Button"
onPress={async () => { await toggleAll(); }}
onRelease={async () => {
lightRef.turnOff();
await delay(100);
switchRef.turnOff();
}}
/>
</esphome>
);
}

export default <App />;

CLI Commands

CommandDescriptionRequires ESPHome
espcompose init <name>Scaffold a new project (--board, --library)
espcompose transpile [dir]Transpile TSX to YAML
espcompose config [dir]Transpile + validate via esphome configYes
espcompose build [dir]Transpile + compile firmwareYes
espcompose run [dir]Transpile + compile + upload to device (--host for local SDL2 preview)Yes
espcompose logs [dir]Stream serial logsYes

Pass extra flags to ESPHome after --:

espcompose run ./my-device -- --device /dev/ttyUSB0

Secrets

Use the secret() function to reference values from your ESPHome secrets.yaml file:

import { secret } from '@espcompose/core';

<wifi ssid={secret('wifi_ssid')} password={secret('wifi_password')} />
<api encryption={{ key: secret('api_encryption_key') }} />

The compiler emits !secret <key> references in the YAML output.

Next Steps

See the espcompose-demo repository for a full working project with components, refs, scripts, and more.