작성일 기준으로 아직 공개되지 않은 v1버전을 기준으로 작성되었습니다.
Zag.js 는 상태 머신을 기반으로 동작하는 ui 컴포넌트 라이브러리이다. 버튼, 아코디언과 같이 널리 쓰이는 컴포넌트부터 날짜 기간 고르기와 같이 구현하기 까다로운 컴포넌트까지 제공한다. 다른 headless ui 라이브러리와 다른점은, 상태 머신을 기반으로 구현했다는 점과, ui 프레임워크와 완전이 agnostic 하여 어떤 프레임워크에서도 사용할 수 있다는 점이다. 퍼스트 파티로 React, Solid, Vue, Svelte 와의 통합을 제공한다.
누가 왜 만들었나?
Zag.js 는 Chakra UI 를 만든 팀에서 만들었다.
Zag.js 의 소개 글을 보면, Chakra UI React를 개발하면서 이벤트 핸들링, 상태, 사이드 이펙트와 관련된 많은 버그를 겪었는데, 대부분 useEffect
, useCallback
와 연관되어있다고 한다.
그리고, Chakra UI 는 Vue 를 위한 버전을 만들었는데, 이때도 비슷한 문제를 겪었고, 곧 이는 유지보수를 어렵게 만들었다.
Chakra UI 팀은 프레임워크와 관계없이 UI 컴포넌트는 항상 같은 동작을 해야한다고 생각했고, 이에 따라 Zag.js를 만들기 시작했다.
Chakra UI 팀은 모던 UI 라이브러리를 위한 4가지 핵심 기술로
- 상태 머신
- 스타일 엔진
- 애니메이션 엔진
- 접근성 팩토리
을 꼽았는데, 이 중 상태 머신의 구현체가 Zag.js 이다. 그리고 스타일 엔진이 PandaCSS이니, Chakra 팀의 프론트엔드 오픈소스에 대한 기여가 더욱 기대된다.

상태 머신을 어떻게 사용했을까?
상태 머신이란 한정된 상태간에 정해진 transition이 발생하는 시스템이다. 상태간의 이동과 이벤트, 이에 따른 사이드 이펙트를 차트로 그릴 수 있다. 견고한 상태 머신은 컴포넌트의 동작을 예측가능하게 만들어준다. zag.js에서는 createMachine이라는 내부 유틸을 사용해서 머신을 정의하는데, 이는 xstate와 거의 비슷한 api 를 가지고 있다. 아래는 Toggle 컴포넌트의 간단한 구현부이다.
import { createMachine } from "@zag-js/core"
const machine = createMachine({
// initial state
initial: "active",
// the finite states
states: {
active: {
on: {
CLICK: {
// go to inactive
target: "inactive",
},
},
},
inactive: {
on: {
CLICK: {
// go to active
target: "active",
},
},
},
},
})
위와 같이 컴포넌트의 핵심 로직을 상태 머신으로 정의하면, 이를 차트로 그릴 수 있다. 실제로 zag.js는 이를 차트로 시각화하여 보여준다. 아래는 Popover 컴포넌트의 실제 차트이다.

상태 머신을 어떻게 ui와 통합했을까?
위 로직은 ui와는 결합이 없는, 코어한 로직이다. 이를 ui dom과 통합하기 위해서, zag.js 는 connect 라는 유틸 함수를 제공한다. wai-aria의 표준을 준수하는 html prop 을 제공한다. 아래는 Toggle 컴포넌트 connect 함수의 간단한 예시이다.
function connect(state, send) {
const active = state.matches("active")
return {
active,
getButtonProps() {
type: "button",
role: "switch",
"aria-checked": active,
onClick() {
send("CLICK")
},
},
}
}
이를 ui 라이브러리와 통합하기 위해서, zag.js 는 프레임워크별 통합 도구를 제공한다. 아래는 useMachine을 사용한 react 의 예시이다.
import { useMachine } from "@zag-js/react"
import { machine, connect } from "./toggle"
function Toggle() {
const [state, send] = useMachine(machine)
const api = connect(state, send)
return (
<button
{...api.getButtonProps()}
style={{
width: "40px",
height: "24px",
borderRadius: "999px",
background: api.active ? "green" : "gray",
}}
>
{api.active ? "ON" : "OFF"}
</button>
)
}
구현 알아보기
이제 라이브러리에 대해서 간단히 알게 되었으니, 자세한 구현을 알아보자.
.xstate
가장 먼저 눈에 띄는 디렉토리는 .xstate
이다. 경로 내부에는 컴포넌트를 정의한 머신 파일들이 있다.
내가 알기로 zag.js는 자체적으로 구현한 상태머신을 사용하는데, 이 머신들은 어디에 쓰이는 걸까?
.github/workflows/visualize.yml
을 보니, state-machine-viz.vercel.app
에 업로드하고 있는것을 알 수 있었다. 실제 구현은 자체 머신으로 되어있지만, visualize 는 xstate 에서 제공하는 도구를 사용하는 것 같다.
e2e
e2e 테스트는 playwright를 사용하고 있다. package.json을 보니 vitest 를 테스팅 프레임워크로 사용하고 있다. 퍼스트 파티로 제공하는 react, vue, solid 를 e2e 테스트하고 있다. svelete 는 안하는 것 같다.
examples
하위의 프레임워크별 구현 예시를 호스팅하고, 여기에 접속해서 테스트한다.
plop
plop은 코드 생성기이다. zag.js는 plop을 사용하여 컴포넌트를 생성한다. plop
하위 디렉토리를 보면, 컴포넌트를 생성하는 템플릿을 .hbs파일로 정의하고 있다. 이를 사용하면, 컴포넌트를 생성할 때, boilerplate 코드를 자동으로 생성할 수 있다.
package.json을 보니, generate-machine
, generate-util
명령어로 실행할 수 있다.
vite.config.ts, tsup.config.ts
vite와 tsup을 사용하고 있다. vite는 vitest 통합을 위한 도구로 사용하고, tsup은 번들러로 사용하고 있다. go 기반의 빠른 빌드를 사용하기 위함으로 보인다. vite는 js 기반의 rollup을 사용하기 때문에 상대적으로 속도가 느릴 것 같다. tsup은 esbuild를 사용하고 있다. changelog 를 보면, 이전에는 vite 로 빌드도 수행했다는 사실을 알 수 있다.
packages/anatomy
zag.js에서는 하나의 컴포넌트를 구성하기 위해서 여러개의 하위 컴포넌트를 사용한다. 예를 들어 Accordion 컴포넌트는 아래와 같이 구성되어있다.

각 부분들은 선언적인 코드로 정의되어있는데, 이를 위한 유틸 함수의 모음이 packages/anatomy에 있다. 위 Accordion 컴포넌트를 구성하는 코드는 아래와 같다.
export const anatomy = createAnatomy("accordion").parts("root", "item", "itemTrigger", "itemContent", "itemIndicator")
part간 위계나 호출 순서에 따른 제약에 필요한 정의는 따로 없는 것 같다. 엄격한 디자인 시스템을 만든다면, 개발 환경에서 호출 순서를 강제하도록 구현해도 좋을 것 같다. 또는 linter 를 사용할 수 있을 것이다.
packages/anatomy createAnatomy()
컴포넌트 anatomy를 선언하는데 사용되는 함수이다. 아래와 같은 인터페이스를 가지고 있다.
export interface Anatomy<T extends string> {
parts: <U extends string>(...parts: U[]) => AnatomyInstance<U>
extendWith: <V extends string>(...parts: V[]) => AnatomyInstance<T | V>
build: () => Record<T, AnatomyPart>
rename: (newName: string) => Anatomy<T>
keys: () => T[]
}
잘 정리된 인터페이스를 가지고 있는 것처럼 보인다. 하지만 실제로 parts 와 build 를 제외하면 test 코드에서만 사용되고 있다. build() 메소드는 parts 를 반환하는데, 이는 connect 함수에서 각 anatomy 엘러먼트(part)를 렌더릴할때 html attribute 을 생성하는데 사용된다. 예를 들면, 각 part 의 dom 에는 data-scope (컴포넌트의 이름) 와 data-part (part 의 이름) 가 정의되어있는데, 이에 대한 선언이 포함된다.
function connect() {
return {
getRootProps() {
return normalize.element({
...parts.root.attrs,
dir: prop("dir"),
id: dom.getRootId(scope),
"data-orientation": prop("orientation"),
})
},
}
}

packages/anatomy-icons
문서에서 anatomy를 시각화하기 위한 svg들의 모음이다. 라이브러리의 동작에는 포함되지 않는다.
packages/core
zag.js의 핵심이 되는 패키지이다. 상태 머신을 생성하는 함수, 상태 머신을 정의하기 위한 유틸 함수, guard 함수를 생성하는 함수, memo 함수, dom을 다루는 함수 등이 있다.
packages/core createMachine()
상태 머신을 생성하는 함수. xstate의 핵심 기능과, zag의 컴포넌트를 구현하는데 필요한 부분을 추가로 구현했다. type defnition용 보일러플레이트 함수이다. 실제 머신에 대한 구현은 각 프레임워크의 구현체에 의존한다.
packages/core setup()
상태머신을 정의하기 위한 유틸을 반환하는 함수. 반환값에 체이닝하여 createMachine
을 호출할 수 있다.
const { choose, guards, createMachine } = setup<NumberInputSchema>()
packages/core createGuards()
and
, or
, not
등의 guard 함수를 생성하는 함수. transition 이 발생하기 전에 조건을 검사할 수 있다.
packages/core memo()
연산이 비싼 함수의 결과를 캐싱하는 함수. createMachine의 computetd 속성에 사용된다. 실제 구현을 찾아보면, 모든 computed가 아닌 필요한 부분에 한해서 사용되는 것을 알 수 있다.
{
computed: {
canIncrement: ({ prop, computed }) => prop("allowOverflow") || !computed("isAtMax"),
canDecrement: ({ prop, computed }) => prop("allowOverflow") || !computed("isAtMin"),
valueText: ({ prop, context }) => prop("translations").valueText?.(context.get("value")),
formatter: memo(
({ prop }) => [prop("locale"), prop("formatOptions")],
(locale, formatOptions) => createFormatter(locale, formatOptions),
),
parser: memo(
({ prop }) => [prop("locale"), prop("formatOptions")],
(locale, formatOptions) => createParser(locale, formatOptions),
),
},
}
packages/core createScope()
dom 을 다루는데 필요한 dom 을 정의하는 함수. iframe을 사용하는 등 특수한 document에서 사용하는경우 커스텀 가능하다. ui 라이브러리와 통합할때 사용된다. react 의 경우, useMachine 안에서 호출된다.
packages/core mergeProps()
zag.js 는 ui 를 그릴 때 spread operator 를 사용하기 때문에, 사용자가 직접 prop을 넘기기 어렵다. 그래서 core에서는 mergeProps라는 함수를 제공한다. 여기에서 event listener, class, style 등 속성을 컴포넌트 기본 속성과 합칠 수 있다. 구현부를 찾아보면, react, solid 등 라이브러리에 따른 분기 없이 조건문으로 모든 로직을 처리한 것을 알 수 있다. 코드가 약간 더러워졌지만, 중복된 코드를 줄이기 위한 나름 합리적인 선택으로 보인다.
react 의 경우 class 를 정의하는데 className을 사용하고, solid 의 경우는 class를 사용한다.
if (key === "className" || key === "class") {
result[key] = clsx(result[key], props[key])
continue
}
if (key === "style") {
result[key] = css(result[key], props[key])
continue
}
react 의 경우 style 를 정의하는데 객체를 사용하고, solid 의 경우는 string을 사용한다.
const css = (
a: Record<string, string> | string | undefined,
b: Record<string, string> | string | undefined,
): Record<string, string> | string => {
if (isString(a)) {
if (isString(b)) return `${a};${b}`
a = serialize(a)
} else if (isString(b)) {
b = serialize(b)
}
return Object.assign({}, a ?? {}, b ?? {})
}
core의 export를 그대로 export 하고 있다.
// packages/frameworks/react/src/index.ts
export { mergeProps } from "@zag-js/core"
export * from "./machine"
export * from "./normalize-props"
export * from "./portal"
// /packages/frameworks/solid/src/index.ts
export { Key } from "@solid-primitives/keyed"
export * from "./machine"
export { mergeProps } from "./merge-props"
export * from "./normalize-props"
packages/docs
api를 json 형식으로 문서화했다. website 에서 이를 기반으로 문서를 생성한다.
packages/frameworks
zag.js 는 프레임워크별로 통합을 제공한다. preact, react, solid, svelte, vue를 지원한다. 이 글에서는 react에 집중하여 알아본다.
packages/frameworks/react packages.json
vitest 를 테스트 도구로 사용하고 있다. tsup을 빌드 도구로 사용하고 있다. react peer dependency는 18이상이다.
packages/frameworks/react useMachine()
machine을 react 에서 사용하기 위한 훅이다. 훅의 상단부에 보기에 어색한 부분이 있는데, 바로 scope의 선언부이다. core의 createScope를 userProp 파라미터와 합치는데, 이를 useMemo 로 감싸고 deps에 userProp을 넣었다.
const scope = useMemo(() => {
const { id, ids, getRootNode } = userProps as any
return createScope({ id, ids, getRootNode })
}, [userProps])
이때, userProps의 참조가 바뀔 때마다 scope는 재생성될 것이다. 그런데 next의 예시를 살펴보면?
const service = useMachine(progress.machine, {
id: useId(),
...controls.context,
})
이렇게 매 랜더링시에 새로운 객체를 넘기는 것을 알 수 있다. 이러면 거의 모든 렌더링에 memo를 새로 생성하게 되므로, memoization이 오히려 성능저하를 유발할 수 있다.
useMachine 에서 prop 을 읽을때는 machine.prop을 compact로 감싸서 읽는다.
const props: any = machine.props?.({ props: compact(userProps), scope }) ?? userProps
compact 함수의 구현부를 보면, undefined 또는 null인 속성을 제거하는 함수이다. 객체의 크기를 최대한 줄이기 위함으로 보인다. 이는 재귀적으로 호출되므로 prop을 구성하는데 주의가 필요하다. 극단적인 경우 메모리 부족으로 인한 성능저하가 발생할 수 있다.
이후 prop을 사용하기 위해 useProp이라는 훅을 사용하는데, 이는 react 의 lifecycle 상 값을 다루기 편하게 하기 위함으로 보인다. 여기서 prop은 useMachine의 인자로만 넘길 수 있으므로, 단방향 데이터 변경이 이루어저 get하는 부분만으로 구현했다.
function useLiveRef<T>(value: T) {
const ref = useRef(value)
ref.current = value
return ref
}
function useProp<T>(value: T) {
const ref = useLiveRef(value)
return function get<K extends keyof T>(key: K): T[K] {
return ref.current[key]
}
}
useMachine 에서 context는 xstate의 context와 유사하지만, 그 구현은 props 와 context와 분리되어 구현된다. 명시적으로 machine 의 외부에서 넘겨주는 값은 prop을 사용했고, 내부적으로 바뀌는 상태는 context로 사용한다. machine에서는 context를 함수로 정의하는데, 이를 위한 유틸 함수를 첫번째 인자에 object형태로 넘겨준다.
const context = machine.context?.({
prop,
bindable: useBindable,
scope,
flush,
getContext() {
return ctx as any
},
getComputed() {
return computed as any
},
})
그러면 context
는 Record<string,Bindable>
의 형태인데, 이를 내부적으로 다루기 쉽도록 감싼 객체가 ctx이다.
const contextRef = useLiveRef<any>(context)
const ctx: BindableContext<T> = {
get(key) {
return contextRef.current?.[key].ref.current
},
set(key, value) {
contextRef.current?.[key].set(value)
},
initial(key) {
return contextRef.current?.[key].initial
},
hash(key) {
const current = contextRef.current?.[key].get()
return contextRef.current?.[key].hash(current)
},
}
machine의 state 간 이동은 event에 의해 발생한다. useMachine에는 현재 이벤트와 이전 이벤트를 저장하는 로직이 포함되어있다.
const previousEventRef = useRef<any>(null)
const eventRef = useRef<any>({ type: "" })
const getEvent = () => ({
...eventRef.current,
current() {
return eventRef.current
},
previous() {
return previousEventRef.current
},
})
const send = (event: any) => {
queueMicrotask(() => {
previousEventRef.current = eventRef.current
eventRef.current = event
// 생략
})
}
action 을 실행하기 위해서 action 이라는 함수를 구현했다. 이 부분에서 살펴볼 부분이 있는데, keys가 함수인지 문자열 배열인지 구분을 한다. keys가 함수이면 실행하고, 아니면 그대로 둔다.
const action = (keys: ActionsOrFn<T> | undefined) => {
// 1. 함수 실행 / 문자열 반환
const strs = isFunction(keys) ? keys(getParams()) : keys
// 2. 문자열을 반환한 경우, 구현체 실행
if (!strs) return
const fns = strs.map((s) => {
const fn = machine.implementations?.actions?.[s]
if (!fn) warn(`[zag-js] No implementation found for action "${JSON.stringify(s)}"`)
return fn
})
for (const fn of fns) {
fn?.(getParams())
}
}
keys의 타입 정의를 보면 아래와 같다.
export type ActionsOrFn<T extends Dict> = T["action"][] | ((params: Params<T>) => T["action"][] | undefined)
함수인 경우, 문자열 배열을 반환하거나 void를 반환한다. 문자열을 반환한 경우, 또다른 action을 실행할 수 있다.
guard
는 비교적 간단한 구현으로 이루어져있다.
const guard = (str: T["guard"] | GuardFn<T>) => {
if (isFunction(str)) return str(getParams())
return machine.implementations?.guards?.[str](getParams())
}
boolean 을 반환하는 함수라서 실행결과를 반환한다. transition중 다음 state 를 선택하는 choose 함수에서 사용된다. 이때, 여러개의 state 중 처음으로 guard가 true 를 반환하는 state 가 선택된다.
effect
는 action과 비슷한데, action이 state 를 변경하는데 사용되고, effect는 side effect를 발생시키는데 사용된다. action과 마찬가지로, keys가 함수인지 문자열 배열인지 구분을 한다. 함수를 반환하면, cleanup 함수로서 effects map 에 저장되었다가 이후 state 가 변하면 실행된다.
computed
는 prop이나 context등의 값을 사용해서 의미있는 값을 읽을 수 있도록 하는 속성이다. machine을 정의할때는 함수로 정의한다. useMachine의 service 에서는 computed 함수를 통해 정의된 computed 속성을 읽을 수 있게 한다.
choose
는 이벤트가 발생했을 때, 다음 state를 고르기 위해서 사용되는 함수이다. 여러개의 state가 가능하다면, guard를 만족하는 가장 첫 state를 선택한다.
state
는 재미있게 구현되어있다. useBindable의 구현체를 사용해서 라이프사이클에 맞는 로직을 수행한다.
const state = useBindable(() => ({
defaultValue: machine.initialState({ prop }),
onChange(nextState, prevState) {
// compute effects: exit -> transition -> enter
// exit effects
if (prevState) {
const exitEffects = effects.current.get(prevState)
exitEffects?.()
effects.current.delete(prevState)
}
// exit actions
if (prevState) {
// @ts-ignore
action(machine.states[prevState]?.exit)
}
// transition actions
action(transitionRef.current?.actions)
// enter effect
// @ts-ignore
const cleanup = effect(machine.states[nextState]?.effects)
if (cleanup) effects.current.set(nextState as string, cleanup)
// root entry actions
if (prevState === "__init__") {
action(machine.entry)
const cleanup = effect(machine.effects)
if (cleanup) effects.current.set("__init__", cleanup)
}
// enter actions
// @ts-ignore
action(machine.states[nextState]?.entry)
},
}))
위의 구현들이 이를 위한 것임을 알 수 있다. 각 함수의 위계에 맞게 로직을 깔끔하게 작성했다.
send
는 아마도 가장 많이 쓰이는 service 일 것 이다.
구현상 특별한 부분은 없고, 마지막에 state를 업데이트하는 부분을 보면 된다.
const changed = target !== currentState
if (changed) {
// state change is high priority
flushSync(() => state.set(target))
} else if (transition.reenter && !changed) {
// reenter will re-invoke the current state
state.invoke(currentState, currentState)
} else {
// call transition actions
action(transition.actions ?? [])
}
send 함수 호출로 1. 상태가 변경 되었을 때, 2. 상태는 같지만, reenter:true일때, 3. 상태가 변하지 않았을때 이다. reenter가 true일때는 상태가 변하지 않아도 effect, action(entry, exit을) 실행한다. 반대일때는, action만 수행하는 것을 알 수 있다.
궁금해서 reenter:true인 machine을 찾아보았다. navigation-menu 의 machine에서 찾을 수 있는데, state가 open 에서 open이 될때, entry 와 exit action이 실행될 것이다.
{
reenter: true,
target: "open",
actions: ["setPreviousValue", "setValue"],
},
packages/frameworks/react useBindable()
값을 다루기 편한 인터페이스를 제공한다.
export interface Bindable<T> {
initial: T | undefined
ref: any
get: () => T
set(value: ValueOrFn<T>): void
invoke(nextValue: T, prevValue: T): void
hash(value: T): string
}
bindable은 머신에서 상태를 선언하기 위해 사용된다.
context({ prop, bindable }) {
return {
focusedValue: bindable<string | null>(() => ({
defaultValue: null,
sync: true,
onChange(value) {
prop("onFocusChange")?.({ value })
},
})),
value: bindable<string[]>(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
onChange(value) {
prop("onValueChange")?.({ value })
},
})),
}
},
위는 accordion 컴포넌트의 예시인데, onChange등의 속성을 사용해서 상태가 업데이트될때 로직을 수행할 수 있다.
sync
속성을 사용하면, 상태의 우선순위를 높이기 위해 flushSync가 사용된다. solid.js는 상태 배치 업데이트가 없다보니, sync param을 무시한다.
useSafeLayoutEffect
를 훅으로 사용하여, useLayoutEffect를 사용할 수 없는 서버사이드 환경에서 사용할 수 있도록 했다.
controlled
속성을 사용하면, 내부의 상태가 아닌 params를 통해 주입한 value를 값으로 사용한다.
onChange
는 값이 바뀌었을때만 실행되는데, 이를 강제로 유도하기 위한 invoke
함수를 제공한다.
packages/machines
각 컴포넌트의 상태 머신을 정의한 패키지이다. 이 글에서는 Accordion의 머신을 살펴본다.
packages/machines accordion.anatomy.ts
위에서 정의한 createAnatomy를 사용하여, 컴포넌트의 anatomy를 정의한다.
packages/machines accordion.connect.ts
컴포넌트의 핵심 상태를 ui 와 통합하기 위한 함수이다. 컴포넌트의 상태에 맞는 각 파트의 props를 반환한다. normalize 함수를 인자로 받아 각 프레임워크에 맞는 반환값을 생성한다.
getItemProps(props: ItemProps) {
const itemState = getItemState(props)
return normalize.element({
...parts.item.attrs,
dir: prop("dir"),
id: dom.getItemId(scope, props.value),
"data-state": itemState.expanded ? "open" : "closed",
"data-focus": dataAttr(itemState.focused),
"data-disabled": dataAttr(itemState.disabled),
"data-orientation": prop("orientation"),
})
},
service로부터 필요한 함수를 분해하여 각 로직에 맞게 사용한다. 이외에는 특별히 주목할만한 구현은 없어보인다.
packages/machines accordion.dom.ts
service로부터 받은 scope 를 이용해, 각 part의 id를 생성하고, dom 을 반환하는 유틸 함수의 모음이다. scope 의 id 를 사용하는데, 이는 전역에서 고유한 값이어야 한다. react의 useId 훅을 사용하는 방법을 사용할 수 있다. 다만 useMachine의 구현을 보면 id 가 필수 값이 아닌 것 같은데 (v1기준), 이때 어떻게 해결했는지 찾아보자. 0.82 버전에서는 typescript required 유틸리티 타입을 사용해 이를 해결했다.
export type UserDefinedContext = RequiredBy<PublicContext, "id">
그런데 v1에서는 프레임워크 통합코드가 완전히 새로 작성되었는데, 우선 machine 이 함수에서 객체로 바뀌었다. 그리고 prop을 useMachine의 인자로 받는다. 그런데 아무리 찾아봐도 id 를 강제하는 코드는 없다.
export function useMachine<T extends BaseSchema>(
machine: MachineConfig<T>,
userProps: Partial<T["props"]> = {},
): Service<T>
아마도 id가 필요한 시나리오가 한정되어있다보니, id가 필수값이 아닌 것 같다. 예를 들면 presence 와 같은 머신에서는 id 를 필요로하지 않는다. 아마도 이런 경우를 모두 고려하기보다 개발자에게 책임을 위임하는 방식을 선택한 것 같다. 리소스가 한정되어있는 상황에서 나름 합리적으로 보인다. 다만, 의도치 않은 동작을 방지하기 위해 문서화를 철저히 해야 할 것이다.

packages/machines accordion.machine.ts
props({ props }) {
return {
/**
* 기본 값들
*/
collapsible: false,
multiple: false,
orientation: "vertical",
defaultValue: [],
/**
* 넘겨준 값들
*/
...props,
}
},
컴포넌트의 상태 머신을 정의했다. props 속성으로 machine 을 처음 사용할 때 (리액트의 경우, useMachine 호출) 속성을 외부에서 넘겨줄 수 있도록 했다. createMachine 에 제네릭으로 넘긴 타입에서 기본값을 정의한다. 이후 머신 또는 connect 에서 prop 을 함수로 호출하여 읽을 수 있다. xstate의 context 와 유사하지만, 함수를 호출함으로서 값을 읽을 수 있다. zag의 머신에서는 context 또한 가지고 있다. 자세한 설명은 위를 참고한다.
initialState() {
return "idle"
},
initial state 를 설정한다. xstate와는 달리, 함수 형태로 정의하여 prop을 읽고서 initial state를 정의할 수 있다. xstate에서 불편했던 점을 개선했다.
context({ prop, bindable, flush, getComputed, getContext, scope }) {
return {
focusedValue: bindable<string | null>(() => ({
defaultValue: null,
onChange(value) {
prop("onFocusChange")?.({ value })
},
})),
value: bindable<string[]>(() => ({
defaultValue: prop("defaultValue"),
value: prop("value"),
onChange(value) {
prop("onValueChange")?.({ value })
},
})),
}
},
context의 정의부이다. bindable 이라는 함수를 이용하여 값을 정의했다. 리액트에서는 useBindable이라는 훅의 반환값이 사용된다. 내부적으로 useState를 사용한다. prop은 머신 외부에서 설정한 값(또는 기본 설정 값)이고, 로직에 따라 바뀌는 값은 context로 정의한 것을 알 수 있다.
function flush(fn: VoidFunction) {
queueMicrotask(() => {
flushSync(() => fn())
})
}
flush(() => {
context.set("size", { height, width })
})
flush 는 react의 flushSync 함수와 queueMicrotask 를 호출하여 렌더링 전에 값을 갱신할 수 있도록 한다. 렌더링 전 dom의 너비등을 확인해야 할 때 사용한다.
{
states: {
focused: {
on: {
"TRIGGER.CLICK": [
{
guard: and("isExpanded", "canToggle"),
actions: ["collapse"],
},
{
guard: not("isExpanded"),
actions: ["expand"],
},
],
"GOTO.FIRST": {
actions: ["focusFirstTrigger"],
},
},
},
},
implementations: {
guards: {
canToggle: ({ prop }) => !!prop("collapsible") || !!prop("multiple"),
isExpanded: ({ context, event }) => context.get("value").includes(event.value),
},
actions: {
collapse({ context, prop, event }) {
const next = prop("multiple") ? remove<string>(context.get("value"), event.value) : []
context.set("value", next)
},
},
}
}
나머지 부분에서 주목할만한 부분은 states는 논리적인 선언만 하고, 실제 구현은 implementations하위에서 했다는 것이다. xstate v5에서는 같은 문제를 setup() 함수로 해결했는데, 같은 문제를 다른 방식으로 해결했다.
const fn = machine.implementations?.actions?.[s]
useMachine에서 이런식으로 호출하게 된다.
packages/machines accordion.props.ts
컴포넌트를 사용하는데 필요한 prop 을 정의하고, prop을 사용하는데 도움이 되는 유틸을 제공한다.
const [machineProps, restProps] = collapsible.splitProps(props)
예를 들면, 위와 같이 object에서 컴포넌트에서 사용되는 props 와 나머지를 분리할 수 있다.
이 모듈에서는 두개의 유틸리티 함수가 사용된다. createProps
는 여러개의 key를 받아서 고유함을 보장한다. array와 set을 사용해 간단하게 구현했다.
createSplitProps
또한 object에서 배열에 없는 key와 있는 key를 튜플로 만들어서 반환하는 간단한 함수이다.
packages/machines accordion.types.ts
컴포넌트를 위한 typescript type의 모음이다. id를 필수로 제공해야하는 부분을 리팩토링하기 위해서는 여기의 구현부를 개선할 수 있을 것이다.
주목할 만한 점
언제 쓰면 좋을까?
- 상태 머신을 기반으로 동작하고, 이를 차트로 시각화하여 보여준다. 이는 개발자가 컴포넌트의 동작을 더 예측가능하게 한다. 복잡한 컴포넌트를 사용할 수록 이에 따른 효용을 느낄 수 있다. 필요한 경우, 동작을 원하는대로 수정할 수 있다.
- 만약 많이 쓰이지 않는 ui 프레임워크를 사용하고 있는 경우, 개발자가 직접 브릿지를 구현하여 완성도 높은 ui 라이브러리를 빠르게 포트할 수 있다.
- headless 한 구현이기 때문에 zagjs를 사용하여 디자인 시스템을 만들 수 있다.
덧붙이는 말
Accordion 컴포넌트의 connect 함수를 알아보다가, 버그를 발견하여 pr 을 작성하여 main 에 머지했다. zag.js에 기여할 수 있는 좋은 기회였다.
