Xstate 를 사용해보면서...

Peter·2024년 5월 21일
0

xstate

기존 상태와 상태 변경 로직

  • 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>
    	)
    }
  • 위 코드는 이름과 나이 주소를 정보를 입력하되 입력창이 하나인 폼입니다.
    가장먼저 이름을 입력하고 ‘다음’버튼을 누르면 입력창은 ‘나이’ 정보를 받는 입력창이 됩니다.
  • 마찬가지로 나이를 입력하고 ‘다음’버튼을 누르면 입력창은 ‘주소’ 정보를 받도록 변경되고 주소를 입력하면 모든 정보 입력을 마치게 됩니다.
  • 두개의 블록은 모두 같은 기능을하지만 첫번째 블록은 useState를 사용한 상태들을 사용했고
  • 두번째 블록은 useReducer를 상용해 어느정도 흐름을 제한한 상태로 구현했습니다.
  • 두가지 훅의 차이를 결과적으로 먼저 설명해보자면 ‘순차적 시나리오’가 있는 구현체에선 useState는 많은 문제를 가지게 됩니다.

글의 제목처럼 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를 모두 종합적으로 확인해야 합니다. 어떤 상태가 있고 어떤 ui가 어떤 함수를 사용해서 상태를 변경하고 있는지, 특히는 step 상태에 따라서 인풋의 관심이 분기되는 상황에서 어떤 변경 시나리오를 가지게 될지도 파악하기 어렵습니다.

xstate가 해결하고자 하는 문제

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는 상태로부터 상태로의 흐름을 만들어냅니다.

xstate는 영혼 ui는 몸 or xstate는 시나리오 ui는 이벤트 트리거

정해진 흐름대로 흐르도록 길을 만든다

 	const [step, setStep] = useState("name")
	
	function nextStep() {
		setStep(stepProcess[step])
	}

기존 코드를 보면 nextStep 함수를 통해 정해진 값으로 step 상태를 설정할 수 있지만 여전히 setStep 이 노출되어 있기 때문에 불특정한 값으로의 변경 가능성이 열려 있습니다.

하지만 xstate는 앞서 설명했듯이 특정 상태로부터 다음 상태로의 이동 방법이 정해져있기 때문에 변수를 줄인 흐름을 만들어낼 수 있습니다.

UI는 흐름이 진행되도록 이벤트를 발생시킨다

상태의 흐름이 정해져 있기 때문에 ui는 정해진 흐름을 진행시킬 이벤트를 트리거하는 책임만을 맡습니다.

Context, state, action

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>
  );
}
  1. conditionOfState 상태머신은 총 3개의 상태를 가집니다.
    • pending, resolved, rejected 입니다.
    • 현재 컴포넌트가 마운트되면 pending 상태를 바라보도록 초기화됩니다.
  2. 이벤트를 가진 pending
    • 3개의 상태중에 이벤트를 가지는건 pending입니다
    • RESOLVE, REJECT 두개의 이벤트를 가지고 각각 resolved rejected 로의 상태 이동을 목표로 합니다.
    • resolved, rejected 는 이벤트를 가지고 있지 않습니다.
  3. 시나리오
    • pending 상태에서 시작한 상태머신은 RESOLVE, REJECT의 이벤트 발생을 기다립니다.
    • RESOLVE, REJECT 중 하나의 이벤트가 발생하면 각각 resolved rejected 로 상태를 이동합니다.
    • 도달하는 상태에서 다음상태로 넘어가는 로직이 보이지 않다면 시나리오가 종료되었다고 간주할 수 있습니다.
  4. UI의 역할
    • state.matches 를 통해 현재 바라보고 상태가 인자와 일치하는지 평가합니다. 현재 바라보고 있는 상태에 따라 보여줄 화면을 달리할 수 있습니다.
    • send 함수를 통해 정해진 이벤트를 트리거 하고 있습니다. 현재 바라보고 있는 상태에 해당 이벤트가 존재하지 않는 경우 아무일도 벌어지지 않습니다.

Context 변경

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>
  );
}
  1. context
    • 상태 머신안에서 바라보는 상태가 빈번하게 바뀌기 때문에 상태 변경에 따른 누적된 값을 다루도록 하는 것이 context 입니다.
    • context의 값을 변화시키기 위해선 assign 함수를 사용해 기존의 값을 다루면서 변경 값을 반영하거나 누적시킬 수 있습니다.
  2. INCREAMENT, DECREMENT
    • 각각 컨텍스트 안에 count 값을 증가시키거나 감소시키는 이벤트입니다.
    • count 상태 안에 있는 액션들이므로 현재 바라보는 상태가 count 일때만 사용 가능합니다.
  3. context 사용
    • useMachine으로부터 state를 꺼내올 수 있습니다. 그 안에 context 가 있어 사용하고자 하는 값을 가져다가 사용 가능합니다.
  4. 현재 상태
    • initial 의 값을 조정해 상태 머신을 시작하자마자 바라보게 할 상태를 설정할 수 있습니다.
    • 현재 상태는 initial 안에 작성되어 있는 count 입니다.

서로 다른 상태에 따른 Context 변경

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>
  );
}
  1. 구성
    • xstate의 묘미는 여기부터 시작입니다. 상태 기계엔 MINE, YOURS 두개의 상태가 들어있고 각각의 상태엔 INC, DEC, TURN 이라는서로 같은 이름의 이벤트가 설정되어 있습니다.
    • 각각의 이벤트 안 로직은 다릅니다 MINE 안에 있는 이벤트들은 contextmine 이라는 값 변경을 다루고 있고 YOURS 안에 있는 이벤트들은 contextyours라는 값 변경을 다루고 있습니다.
    • TURN은 현재 상태에서 다른쪽 상태로 넘어가기 위한 이벤트입니다.
    • INCDEC 이벤트를 발생시키는 버튼이 위치합니다.
  2. MINE 상태로 시작
    • initialMINE으로 설정되어 있기 때문에 상태가 시작되면 상태는 MINE을 바라봅니다
    • 이때 INC, DEC 버튼을 누르게 되면 context안에 mine 상태의 값이 증가하거나 감소하게 됩니다.
  3. YOURS 상태로 변경
    • TURN 버튼을 누르게 되면 현재 바라보고 있는 상태가 MINE 이기 때문에 target: “YOURS”가 실행되어 바라보는 상태가 변경되기 됩니다.
  4. 같은 UI 동작
    • 상태가 변경된 상태에서 INC, DEC 버튼을 동작하게 되면 이번엔 mine 값이 변경되는것이 아니라 yours 값이 변경됩니다.
  5. 모드 체인지
    • 결국 같은 이벤트 이름이지만 바라보는 상태에 따라 다르게 동작하고 있습니다.
    • 바라보는 상태가 변경되면 변경되기 전 상태가 가지고 있던 이벤트들은 은닉되어 동작할 수 없게 됩니다.
    • 다른 상태 같은 이름의 이벤트를 통해서 모드가 변경되는 듯한 효과를 연출할 수 있습니다.

진입과 동시에 진행되는 액션

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 을 키값으로 설정이 가능합니다.
  • 현재는 실행과 동시에 contextcount 값을 alert로 띄우게 됩니다.

상태 가드

  • GuardState
    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>
      );
    }
    
  1. 현재 바라보고 있는 상태는 INC 입니다.
  2. INC 에서 actions 이벤트가 발생하면 context에 있는 count 가 증가하게 됩니다.
  3. 이 과정에서 guard 라는 키가 등장하게 되는데 쉽게 설명하자면 guard의 조건이 참이어야만 actions가 실행되도록 설정되어 있습니다.
  4. 현재 guard“isValid”가 설정되어 있고 이 가드는 count‘10 미만일때 true 를 반환’하고 있습니다.
  5. INC 상태의 actions가 실행되기 위해선 context의 count 가 10미만이어야 하고 그렇지 않으면 actions가 실행되지 않습니다.
  6. 이것이 guard의 역할입니다.
  7. guard를 응용하면 다중 입력 폼등의 유효성 검사를 별로의 상태로 관리하지 않고 xstate의 시나리오 안쪽으로 가지고 와 관리할 수 있습니다.
  8. 지금은 단순히 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>
  );
}
  1. 위 코드를 보면 context로 count 라는 값을 가지고 있고
  2. ui에선 이 count의 현황에 대해 표현하면서 action 이라는 버튼 하나를 가지고 있습니다.
  3. xstate 머신 내부를 살펴보면 INC , DEC 두개의 상태가 존재하고 있습니다.
  4. 각각의 상태는 action 이라는 같은 이름의 이벤트를 가지고 있고 각각 context의 count 값을 증가시키고 감소시키는 역할을 수행하게 됩니다.
  5. 그리고 액션안엔 guard 가 설정되어 있는데 각각 “isOverTen”, “isUnderMinusTen” 이라는 가드가 설정되어 있습니다.
  6. isOverTen은 context count 가 9를 초과할때 true를 반환합니다
  7. isUnderMinusTen은 context count가 -9 미만일때 true를 반환합니다.
  8. 초기 상태는 INC 로 context count 값은 0으로 시작합니다.
  9. ui의 버튼을 통해 action 을 계속 실행하다보면 count 값이 10이 되는 순간 isOverTen 가드가 실행됩니다.
  10. { guard: "isOverTen", target: "DEC" } 조금 특별하게 가드가 존재함과 동시에 target 으로 된 키값이 존해하는데 이건 guard가 실행된다면 옮겨갈 상태를 명시하는 기능입니다.
  11. isOverTen 가드가 실행되었으니 INC 에서 DEC로 상태가 옮겨가게 됩니다.
  12. 상태가 옮겨간 순간부터 똑같은 버튼을 누르게 되더라도 context count 값은 감소하기 시작합니다
  13. 계속 감소하기 시작하다가 -10이 되는 순간 “isUnderMinusTen” 가드가 실행됩니다. 이 가드는 “isOverTen” 가드와 마찬가지로 실행과 동시에 INC 로의 상태 변경이 일어나도록 되어 있습니다.
  14. 마찬가지로 INC 의 상태로 다시 action 이 실행되기 때문에 context count가 다시 증가하게 됩니다.
  15. 결국 모든 시나리오를 간략하게 요약 하자면 10과 -10 사이에서 증가하거나 감소시키는 버튼이라는 시나리오가 됩니다.
  16. 이 처럼 가드는 실행되는 순간 특정 액션을 실행하거나 상태를 옮겨가는 등의 정해진 액션을 실행할 수 있습니다.

비동기 상태 관리

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>
  );
}
  1. xstate의 시나리오 안에선 비동기 api 호출 기능도 포함하고 있습니다.
  2. setup을 통해 actors에 함수를 등록한 다음
  3. fromPromise 함수를 통해 비동기 호출 함수를 감싸서 actors에 등록할 수 있습니다
  4. 상태 안에서 invoke 를 통해 비동기 함수를 호출 할 수 있고 onDoneonError 와 같은 비동기의 절차 관련된 단계를 설정할 수 있습니다.

구체적인 시나리오 구현

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>
  );
}
  1. 마지막으로 xstate로 만들어낸 시나리오 대로 사용자의 입력을 받는 화면 코드입니다.
  2. form 태그 안엔 1개의 input과 1개의 button이 있습니다.
  3. 인풋을 입력하고 다음 버튼을 입력하게 되면 이름, 나이, 주소 순으로 값이 채워지게 됩니다.
  4. 중요한 점은 ui 쪽에선 상태 값을 사용하고 정해진 이벤트를 실행시키는 버튼만이 존재하고 실제적으로 상태가 흐르는 모든 시나리오는 xstate 머신안에 담겨있다는 점입니다.
  5. 결국 xstate 머신만 살펴보면 화면과 상태가 어떤 조건을 가지고 어떻게 흘러가게 되는지를 한눈에 파악할 수 있다는 것을 알 수 있습니다.

비대해진 책임

xstate 머신은 값 뿐만 아니라 값을 변화시키는 로직과 값이 변화해가는 흐름등 상태에 대한 모든 것을 다 담고 있습니다. 즉 xstate가 알고 있는 시나리오의 종말까지는 그 과정에서 중단하거나 전환하는 등 xstate 머신을 벗어났다가 돌아오는 변수 창출이 어렵다는 것을 의미합니다.

사용자가 여정을 출발해 정해진 여정의 종말에 이를때까지를 한개의 책임으로 관리하기 때문에 거대한 여정은 비대해진 책임을 의미하는것 같기도 합니다.

작은 컴포넌트부터 작은 컴포넌트들이 합성을 이루는 합성컴포넌트들까지 재사용성을 높이는 과정에서 특정 도메인 성격을 가지는 프로젝트 밖에서도 사용 가능한 컴포넌트들이 만들어지고 사용되고 있습니다만 xstate는 이러한 컴포넌트들을 만들땐 큰 의미를 가지지 못하는것 같습니다.

xstate가 감당할 수 있는 큰 책임과 시나리오는 사용자의 여정이 좀더 입체적이어야 하는 부분을 컴포넌트화 시킬때 유용합니다.

예를 들면 신용카드 등록 과정과 같이 신용카드 정보를 기입하고 사용자 정보를 기입한뒤 어떤 카드인지 선택하고 카드번호를 검증하는 과정을 스텝 바이 스텝으로 구현하려면 여러가지 화면 컴포넌트가 있고 그 과정에서 주고받는 상태와 검증하는 로직들이 필요한데 xstate는 이 모든 과정을 시나리오화 하고 쉽게 관리하게 해줍니다.

정해진 방향대로 정보를 입력하게 하고 그 과정에서 오입력이나 겁증되지 않은 상태 등 예상치 못한 시나리오를 방지할 수 있도록 돕습니다.

지역 상태, 전역 상태 라는 개념 이외에 시계열을 가지는 상태라고 정의할 수 있는 도구인것 같습니다.

profile
컴퓨터가 좋아

0개의 댓글