const stepProcess = {
name: "age",
age: "address"
}
function StepForm(){
const inputRef = useRef(null)
const [step, setStep] = useState("name")
const [name, setName] = useState("")
const [age, setAge] = useState("")
const [address, setAddress] = useState("")
const setter = {
name: setName,
age: setAge,
address, setAddress
}
function nextStep() {
setStep(stepProcess[step])
}
function changeValue() {
setter[step](inputRef.current.value)
nextStep()
}
function submit(){
changeValue()
}
return (
<div>
<div>
<div>이름: {name}</div>
<div>나이: {age}</div>
<div>주소: {address}</div>
</div>
<form onSubmit={submit}>
<div>
{step}:
<input ref={inputRef} />
</div>
<button>다음</button>
</form>
</div>
</div>
)
}
const stepProcess = {
name: "age",
age: "address"
}
const initialState = {
currentStep: "name",
inputs:{
name: "",
age: "",
address:""
},
}
function reducer(state, action) {
switch(action.type) {
case "input":
return {
currentStep: stepProcess[action.key],
inputs: {
...state.inputs,
[action.key]: action.value
}
}
}
}
function StepForm(){
const inputRef = useRef(null)
const [state, dispatch] = useReducer(reducer, initialState);
function submit() {
dispatch({ type: "input", key: state.currentStep, value: inputRef.current.value });
}
return (
<div>
<div>
<div>이름: {state.inputs.name}</div>
<div>나이: {state.inputs.age}</div>
<div>주소: {state.inputs.address}</div>
</div>
<form onSubmit={submit}>
<div>
{state.currentStep}:
<input ref={inputRef} />
</div>
<button>다음</button>
</form>
</div>
</div>
)
}
글의 제목처럼 xstate에 대한 소개 내용이 담기겠지만 그전에 먼저 xstate가 기존 상태의 어떤 문제를 해결하고자 나오게 되었는지 설명이 필요합니다.
const [step, setStep] = useState("name")
const [name, setName] = useState("")
const [age, setAge] = useState("")
const [address, setAddress] = useState("")
먼저 useState 훅을 사용해서 구현한 부분을 살펴보면 시나리오 과정에서 필요한 모든 상태를 만들어줄 필요가 있습니다. 물론 오브젝트 형태를 가진 하나의 상태를 가져도 무리는 없지만 어쨌든 시나리오 상의 현재 필요하지 않은 상태까지도 노출되어 있습니다.
현재 필요하지 않은 상태까지도 노출되어 있다는 의미는, 현재 인풋이 age 값을 받도록 기다리고 있지만 setName, setAddress 같은 setter들도 노출되어 있어 언제든 인풋이 아닌 다른 트리거를 통해 값이 변경될 수 있는 상황이라는 것을 의미합니다.
const setter = {
name: setName,
age: setAge,
address, setAddress
}
function nextStep() {
setStep(stepProcess[step])
}
function changeValue() {
setter[step](inputRef.current.value)
nextStep()
}
function submit(){
changeValue()
}
각각의 상태는 그 상태를 변경하기 위한 setter가 존재하는데 여기선 step, name, age, address 상태를 변경 하기 위한 모든 setter를 설정해줘야 합니다.
setter
라는 객체에 각 상태의 settter를 담아 분기되도록 처리하고 있지만 코드 상에서 setter들이 모두 노출되어 있기 때문에 언제든 다른 함수를 만들어 시나리오에서 벗어난 부분에서 상태 변경 가능성을 보이고 있습니다.
const setter = {
name: setName,
age: setAge,
address, setAddress
}
setter
오브젝트가 현재 시나리오에서 필요한 setter들을 모두 담고 있는 상황입니다. 그렇기 때문에 setter
오프젝트는 시나리오의 관심사 내부에서 동작을 할 수 있습니다. 하지만 언제까지나 단방향적입니다. setName
, setAge
, setAddress
는 여전히 노출되어 있기 때문입니다.
각각의 setter들은 언제든지 다른 함수 혹은 직접적으로 사용가능해 상태 변경가능성을 품고 있습니다.
구현 단계에서 우리는 시나리오를 가지고 상태를 만들고 함수를 만들고 분기를 처리합니다만 완성이 된 이후엔 전체적인 시나리오 맥락을 파악하기가 어렵습니다.
각각의 기능이 어떻게 동작이 되는지, 파편적으로만 확인 가능하기 때문에 전체적인 흐름을 파악하기가 어렵습니다.
전체적인 흐름을 파악하기 위해선 상태, 함수, ui를 모두 종합적으로 확인해야 합니다. 어떤 상태가 있고 어떤 ui가 어떤 함수를 사용해서 상태를 변경하고 있는지, 특히는 step
상태에 따라서 인풋의 관심이 분기되는 상황에서 어떤 변경 시나리오를 가지게 될지도 파악하기 어렵습니다.
State machines help us model how a process goes from state to state when an event occurs.
State machines are useful in software development because they help us capture all the states, events and transitions between them. Using state machines makes it easier to find impossible states and spot undesirable transitions.
xstate는 시나리오에 따른 여러 상태를 다룸과 동시에 이벤트를 기반으로 정해진 프로세스에 따라 상태의 이동 흐름을 직관적으로 다룰 수 있게 돕습니다.
예를 들면 동영상 플레이어를 구현한다고 했을때 재생버튼을 눌러 재생을 하면 재생버튼은 동영상을 일시중지하는 일시중지 버튼으로 바뀌게 됩니다.
일반적인 상태를 사용해서 구현하게 된다면 재생 상태인지를 보여주는 상태가 필요하고 재생과 일시중지를 실행시키는 함수들이 필요합니다. 그리고 상태를 분기해 현재 재생중인지 정지상태인지에 따라 각각의 함수를 실행하게 되는데 문제는 재생과 일시중지를 실행하는 함수가 그대로 노출되어 있다는 점입니다.
xstate는 이런 부분을 직관적으로 다뤄줍니다.
import { createMachine } from 'xstate';
const dogMachine = createMachine({
id: 'dog',
initial: 'asleep',
states: {
asleep: {
on: {
'wakes up': 'awake',
}
},
awake: {
on: {
'falls asleep': 'asleep',
}
},
//...
}
});
send({ type: 'wakes up' });
상태는 asleeep
, awake
두가지를 가지고 있습니다. 시작 상태는 asleep
이고 하고 "wakes on", "falls asleep" 이벤트를 발생시킴에 따라서 상태를 이동하게 됩니다.
현재 send
를 통해 type
이 "wakes up" 인 이벤트를 발생시켰습니다. asleep
의 상태에 "wakes up" 이벤트가 반응하게 되어 상태를 awake
로 변경합니다.
만약 type
을 "wakes up"이 아니라 "falls asleep" 이벤트를 발생시키면 어떻게 될까요? 아무일도 일어나지 않습니다. 현재 사용하고 있는 상태안에서 설정된 이벤트에 대해서만 대기하고 있기 때문에 현재 상태인 asleep
의 "wakes up"에만 반응하게 됩니다.
상태를 변경하는 방법은 명시된대로 각 상태의 정해진 이벤트를 발생시켜야 합니다. 이렇게 xstate는 상태로부터 상태로의 흐름을 만들어냅니다.
const [step, setStep] = useState("name")
function nextStep() {
setStep(stepProcess[step])
}
기존 코드를 보면 nextStep
함수를 통해 정해진 값으로 step 상태를 설정할 수 있지만 여전히 setStep
이 노출되어 있기 때문에 불특정한 값으로의 변경 가능성이 열려 있습니다.
하지만 xstate는 앞서 설명했듯이 특정 상태로부터 다음 상태로의 이동 방법이 정해져있기 때문에 변수를 줄인 흐름을 만들어낼 수 있습니다.
상태의 흐름이 정해져 있기 때문에 ui는 정해진 흐름을 진행시킬 이벤트를 트리거하는 책임만을 맡습니다.
const countState = createMachine({
initial: "count",
context: {
count: 0,
},
states: {
count: {
on: {
INCREMENT: {
actions: assign({ count: ({ context }) => context.count + 1 }),
},
DECREMENT: {
actions: assign({ count: ({ context }) => context.count - 1 }),
},
},
},
},
});
시나리오에 따라 바라보는 상태가 달라지는 xstate는 시나리오가 진행됨에 따라 누적되는 상태가 필요합니다. 위 코드는 보면 증가/감소 하는 액션을 가진 상태입니다. 현재 바라보고 있는 상태는 count 입니다만 언제든 다른 상태를 바라볼 수 있기 때문에 count가 값을 가지게 되면 상태가 변경되었을때 꺼내 사용하기가 어렵습니다.
따라서 시나리오 전체에서 흐르는 context를 설정하고 바라보는 상태가 context를 조정하는 방향으로 상요하게 됩니다.
const conditionOfState = createMachine({
initial: "pending",
states: {
pending: {
on: {
RESOLVE: { target: "resolved" },
REJECT: { target: "rejected" },
},
},
resolved: {
type: "final",
},
rejected: {
type: "final",
},
},
});
전체 시나리오중 현재 진행중인 시나리오를 나타냅니다.
pending
상태로 시작하는 시나리오에서 RESOLVE
또는 REJECT
이벤트가 발생하길 기다립니다.
그리고 각각의 이벤트는 바라보는 상태를 이동하도록 target이 설정되어 있습니다.
RESOLVE 이벤트가 발생하면 상태가 pending 에서 resolved 로 변경되는데, resolved 로의 상태가 변경되면 RESOLVE 나 REJECT 의 이벤트는 더 이상 노출되지 않아 사용할 수 없습니다.
지금까진 xstate가 기존 상태관리가 가진 문제를 어떻게 해결하고자 했는지 간단하게 설명했습니다.
아래부터는 실제 xstate를 사용하면서 마주 할 수 있는 패턴에 대해 소개해보겠습니다.
import { useMachine } from "@xstate/react";
import { createMachine } from "xstate";
const conditionOfState = createMachine({
initial: "pending",
states: {
pending: {
on: {
RESOLVE: { target: "resolved" },
REJECT: { target: "rejected" },
},
},
resolved: {
type: "final",
},
rejected: {
type: "final",
},
},
});
export default function ConditionalState() {
const [state, send] = useMachine(conditionOfState);
return (
<div>
{/** You can listen to what state the service is in */}
{state.matches("pending") && <p>Loading...</p>}
{state.matches("rejected") && <p>Promise Rejected</p>}
{state.matches("resolved") && <p>Promise Resolved</p>}
<div>
{/** You can send events to the running service */}
<button onClick={() => send({ type: "RESOLVE" })}>Resolve</button>
<button onClick={() => send({ type: "REJECT" })}>Reject</button>
</div>
</div>
);
}
pending
상태를 바라보도록 초기화됩니다.pending
입니다RESOLVE
, REJECT
두개의 이벤트를 가지고 각각 resolved
rejected
로의 상태 이동을 목표로 합니다.resolved
, rejected
는 이벤트를 가지고 있지 않습니다.pending
상태에서 시작한 상태머신은 RESOLVE
, REJECT
의 이벤트 발생을 기다립니다.RESOLVE
, REJECT
중 하나의 이벤트가 발생하면 각각 resolved
rejected
로 상태를 이동합니다.import { useMachine } from "@xstate/react";
import { assign, createMachine } from "xstate";
const countState = createMachine({
initial: "count",
context: {
count: 0,
},
states: {
count: {
on: {
INCREMENT: {
actions: assign({ count: ({ context }) => context.count + 1 }),
},
DECREMENT: {
actions: assign({ count: ({ context }) => context.count - 1 }),
},
},
},
},
});
export default function Count() {
const [state, send] = useMachine(countState);
return (
<div>
<div>{state.context.count}</div>
<button onClick={() => send({ type: "INCREMENT" })}>INC</button>
<button onClick={() => send({ type: "DECREMENT" })}>DEC</button>
</div>
);
}
context
입니다.context의 값
을 변화시키기 위해선 assign 함수
를 사용해 기존의 값을 다루면서 변경 값을 반영하거나 누적시킬 수 있습니다.count
값을 증가시키거나 감소시키는 이벤트입니다.count
상태 안에 있는 액션들이므로 현재 바라보는 상태가 count
일때만 사용 가능합니다.useMachine
으로부터 state
를 꺼내올 수 있습니다. 그 안에 context
가 있어 사용하고자 하는 값을 가져다가 사용 가능합니다.initial
의 값을 조정해 상태 머신을 시작하자마자 바라보게 할 상태를 설정할 수 있습니다.initial
안에 작성되어 있는 count
입니다.import { useMachine } from "@xstate/react";
import { assign, createMachine } from "xstate";
const localStateCountState = createMachine({
initial: "MINE",
context: { mine: 0, yours: 0 },
states: {
MINE: {
on: {
INC: {
actions: assign({
mine: ({ context }) => context.mine + 1,
yours: ({ context }) => context.yours - 1,
}),
},
DEC: {
actions: assign({
yours: ({ context }) => context.yours + 1,
mine: ({ context }) => context.mine - 1,
}),
},
TURN: {
target: "YOURS",
},
},
},
YOURS: {
on: {
INC: {
actions: assign({
mine: ({ context }) => context.mine - 1,
yours: ({ context }) => context.yours + 1,
}),
},
DEC: {
actions: assign({
yours: ({ context }) => context.yours - 1,
mine: ({ context }) => context.mine + 1,
}),
},
TURN: {
target: "MINE",
},
},
},
},
});
export default function ModeChangeCount() {
const [state, send] = useMachine(localStateCountState);
const isMine = state.matches("MINE");
return (
<div>
{isMine ? <div>내꺼 통제</div> : <div>너꺼 통제</div>}
<div>
MINE: {state.context.mine} YOURS: {state.context.yours}
</div>
<div>
<div>
<button onClick={() => send({ type: "INC" })}>INC</button>
<button onClick={() => send({ type: "DEC" })}>DEC</button>
</div>
</div>
<div>
<button onClick={() => send({ type: "TURN" })}>Change</button>
</div>
</div>
);
}
MINE
, YOURS
두개의 상태가 들어있고 각각의 상태엔 INC
, DEC
, TURN
이라는서로 같은 이름의 이벤트가 설정되어 있습니다.context
에 mine
이라는 값 변경을 다루고 있고 YOURS 안에 있는 이벤트들은 context
에 yours
라는 값 변경을 다루고 있습니다.TURN
은 현재 상태에서 다른쪽 상태로 넘어가기 위한 이벤트입니다.INC
와 DEC
이벤트를 발생시키는 버튼이 위치합니다.MINE
상태로 시작initial
이 MINE
으로 설정되어 있기 때문에 상태가 시작되면 상태는 MINE
을 바라봅니다INC
, DEC
버튼을 누르게 되면 context
안에 mine
상태의 값이 증가하거나 감소하게 됩니다.YOURS
상태로 변경TURN
버튼을 누르게 되면 현재 바라보고 있는 상태가 MINE
이기 때문에 target: “YOURS”
가 실행되어 바라보는 상태가 변경되기 됩니다.INC
, DEC
버튼을 동작하게 되면 이번엔 mine
값이 변경되는것이 아니라 yours
값이 변경됩니다.import { useMachine } from "@xstate/react";
import { setup } from "xstate";
function initializing(context: unknown, params: { count: number }) {
alert(`init count = ${params.count}`);
}
const entryState = setup({
actions: {
initialize: initializing,
},
}).createMachine({
types: {
context: {} as { count: number },
},
context: {
count: 0,
},
entry: [
{ type: "initialize", params: ({ context }) => ({ count: context.count }) },
],
});
export default function EntriedActionState() {
const [state] = useMachine(entryState);
return <div>count: {state.context.count}</div>;
}
actions
들을 등록해두고 entry
를 통해 어떤 액션을 실행시킬지 설정이 가능합니다(useEffect
의 초기 실행과 비슷)params
을 키값으로 설정이 가능합니다.context
의 count
값을 alert
로 띄우게 됩니다.import { useMachine } from "@xstate/react";
import { assign, createMachine } from "xstate";
const guardState = createMachine(
{
initial: "INC",
context: {
count: 0,
},
states: {
INC: {
on: {
action: {
actions: assign({ count: ({ context }) => context.count + 1 }),
guard: "isValid",
},
},
},
},
},
{
guards: {
isValid: ({ context }) => {
return context.count < 10; // 10 미만이지만 9인순간엔 10 미만이므로 가드 통과하여 값이 10이 나옴
},
},
}
);
export default function GuardState() {
const [state, sendTo] = useMachine(guardState);
return (
<div>
<div>Guarded under 10 :{state.context.count}</div>
<div>
<button onClick={() => sendTo({ type: "action" })}>action</button>
</div>
</div>
);
}
INC
입니다.INC
에서 actions
이벤트가 발생하면 context
에 있는 count
가 증가하게 됩니다.guard
라는 키가 등장하게 되는데 쉽게 설명하자면 guard
의 조건이 참이어야만 actions
가 실행되도록 설정되어 있습니다.guard
는 “isValid”
가 설정되어 있고 이 가드는 count
가 ‘10 미만일때 true 를 반환’하고 있습니다.INC
상태의 actions
가 실행되기 위해선 context의 count
가 10미만이어야 하고 그렇지 않으면 actions
가 실행되지 않습니다.import { useMachine } from "@xstate/react";
import { assign, createMachine } from "xstate";
const conditionalGuardState = createMachine(
{
context: {
count: 0,
},
initial: "INC",
states: {
INC: {
entry: assign({ count: ({ context }) => context.count + 1 }),
on: {
action: [
{ guard: "isOverTen", target: "DEC" },
{
actions: assign({ count: ({ context }) => context.count + 1 }),
},
],
},
},
DEC: {
entry: assign({ count: ({ context }) => context.count - 1 }),
on: {
action: [
{ guard: "isUnderMinusTen", target: "INC" },
{ actions: assign({ count: ({ context }) => context.count - 1 }) },
],
},
},
},
},
{
guards: {
isOverTen: ({ context }) => context.count > 9,
isUnderMinusTen: ({ context }) => context.count < -9,
},
}
);
export default function ConditionalGuardState() {
const [state, sendTo] = useMachine(conditionalGuardState);
return (
<div>
<div>{state.context.count}</div>
<button onClick={() => sendTo({ type: "action" })}>action</button>
</div>
);
}
INC
, DEC
두개의 상태가 존재하고 있습니다.{ guard: "isOverTen", target: "DEC" }
조금 특별하게 가드가 존재함과 동시에 target 으로 된 키값이 존해하는데 이건 guard가 실행된다면 옮겨갈 상태를 명시하는 기능입니다.INC
로의 상태 변경이 일어나도록 되어 있습니다.INC
의 상태로 다시 action 이 실행되기 때문에 context count가 다시 증가하게 됩니다.import { useMachine } from "@xstate/react";
import { Fragment } from "react/jsx-runtime";
import { assign, fromPromise, setup } from "xstate";
async function fetchMovies() {
return await fetch("https://yts.mx/api/v2/list_movies.json").then((res) =>
res.json()
);
}
const asyncState = setup({
actors: {
fetchMovies: fromPromise(fetchMovies),
},
}).createMachine({
initial: "loading",
context: {
movies: [],
},
states: {
loading: {
invoke: {
id: "fetch-movies",
src: "fetchMovies",
onDone: {
target: "loaded",
actions: assign({
movies: ({ event }) => event.output,
}),
},
onError: "failure",
},
},
loaded: {
on: {
REFRESH: "loading",
},
},
failure: {
on: {
RETRY: "loading",
},
},
},
});
export default function AsyncState() {
const [state] = useMachine(asyncState);
const movies: { id: number; title: string; summary: string }[] =
state.context.movies?.data?.movies ?? [];
return (
<div>
{movies.map((movie) => {
return (
<Fragment key={movie.id}>
<h2>{movie.title}</h2>
<div>{movie.summary}</div>
</Fragment>
);
})}
</div>
);
}
setup
을 통해 actors
에 함수를 등록한 다음fromPromise
함수를 통해 비동기 호출 함수를 감싸서 actors
에 등록할 수 있습니다invoke
를 통해 비동기 함수를 호출 할 수 있고 onDone
과 onError
와 같은 비동기의 절차 관련된 단계를 설정할 수 있습니다.import { useMachine } from "@xstate/react";
import { FormEvent, useEffect, useRef } from "react";
import { assign, createMachine } from "xstate";
const humanInfoState = createMachine({
initial: "NAME",
context: {
name: "",
age: null,
address: "",
},
states: {
NAME: {
on: {
input: {
actions: assign({
name: ({ event }) => event.event,
}),
target: "AGE",
},
},
},
AGE: {
on: {
input: {
actions: assign({
age: ({ event }) => event.event,
}),
target: "ADDRESS",
},
},
},
ADDRESS: {
on: {
input: {
actions: assign({
address: ({ event }) => event.event,
}),
},
},
},
},
});
export default function StepForm() {
const inputRef = useRef<HTMLInputElement>(null);
const [state, send] = useMachine(humanInfoState);
const { name, age, address } = state.context;
const STATE_KEYS = Object.keys(state.machine.states);
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
send({ type: "input", event: inputRef.current?.value });
}
useEffect(() => {
inputRef.current?.focus();
}, [state]);
return (
<div>
<div>
<div>이름: {name}</div>
<div>나이: {age}</div>
<div>주소: {address}</div>
</div>
<form onSubmit={submit}>
{STATE_KEYS.map((key) => (
<>
{state.matches(key) && (
<div>
{key}:
<input ref={inputRef} />
</div>
)}
</>
))}
<button>다음</button>
</form>
</div>
);
}
form
태그 안엔 1개의 input
과 1개의 button
이 있습니다.xstate 머신은 값 뿐만 아니라 값을 변화시키는 로직과 값이 변화해가는 흐름등 상태에 대한 모든 것을 다 담고 있습니다. 즉 xstate가 알고 있는 시나리오의 종말까지는 그 과정에서 중단하거나 전환하는 등 xstate 머신을 벗어났다가 돌아오는 변수 창출이 어렵다는 것을 의미합니다.
사용자가 여정을 출발해 정해진 여정의 종말에 이를때까지를 한개의 책임으로 관리하기 때문에 거대한 여정은 비대해진 책임을 의미하는것 같기도 합니다.
작은 컴포넌트부터 작은 컴포넌트들이 합성을 이루는 합성컴포넌트들까지 재사용성을 높이는 과정에서 특정 도메인 성격을 가지는 프로젝트 밖에서도 사용 가능한 컴포넌트들이 만들어지고 사용되고 있습니다만 xstate는 이러한 컴포넌트들을 만들땐 큰 의미를 가지지 못하는것 같습니다.
xstate가 감당할 수 있는 큰 책임과 시나리오는 사용자의 여정이 좀더 입체적이어야 하는 부분을 컴포넌트화 시킬때 유용합니다.
예를 들면 신용카드 등록 과정과 같이 신용카드 정보를 기입하고 사용자 정보를 기입한뒤 어떤 카드인지 선택하고 카드번호를 검증하는 과정을 스텝 바이 스텝으로 구현하려면 여러가지 화면 컴포넌트가 있고 그 과정에서 주고받는 상태와 검증하는 로직들이 필요한데 xstate는 이 모든 과정을 시나리오화 하고 쉽게 관리하게 해줍니다.
정해진 방향대로 정보를 입력하게 하고 그 과정에서 오입력이나 겁증되지 않은 상태 등 예상치 못한 시나리오를 방지할 수 있도록 돕습니다.
지역 상태, 전역 상태 라는 개념 이외에 시계열을 가지는 상태
라고 정의할 수 있는 도구인것 같습니다.