단일 시스템에서 전체 앱의 동작을 표현하는 것은 금방 복잡하고 어려워질 수 있습니다.
대신 복잡한 논리를 표현하기 위해 서로 통신하는 여러 기계를 사용하는 것은 자연스럽고 권장됩니다.
이것은 각 머신 인스턴스가 다른 "액터"(예: 프라미스 또는 기타 머신)와 이벤트(메시지)를 주고받고 이에 반응할 수 있는 "액터"로 간주되는 Actor 모델과 매우 유사합니다.
(즉, 시스템 안과 밖을 각각 Actor로 나누어 책임 분할)
- Promise : resolve 시 onDone 전환을 수행합니다. reject시 onError 전환을 수행합니다.
- Callbacks : 부모로 이벤트를 송신하거나, 부모로부터 이벤트틀 수신할 수 있습니다.
- Observables : 이벤트를 부모 머신에 보낼 수 있을 뿐만 아니라 완료될 때 신호를 보낼 수 있습니다. (받을 수는 없습니다.)
- Machines : 이벤트를 보내고 받을 수도 있고, 최종 상태에 도달하면 부모 머신에 알릴 수도 있습니다.
The invoke Property (invoke의 속성)
invocation은 다음을 포함하는 객체인 invoke 속성을 사용하여 상태 노드의 configuration에 정의됩니다.
- src - 호출할 서비스의 소스, 다음과 같을 수 있습니다.
- 상태 기계
- Promise 리턴 함수
- 콜백 핸들러를 리턴하는 함수
- 옵저버블을 리턴하는 함수
- 문자열 : 이 머신의 options.services에 정의되어 나열된 4가지 옵션 중 하나를 나타내는...
- { type: src }의 소스 문자열과 기타 메타데이터가 포함된 소스 개체
- 대부분 다 이렇게 쓴다.
- id - 호출할 서비스의 유일한 식별자입니다.
- onDone - (선택 사항) 다음과 같은 경우 수행할 전환:
- 자식 머신이 최종 상태에 도달하거나
- 호출된 약속이 reolved
- 호출된 옵저버블이 완료
- onError - (선택 사항) 호출된 서비스가 실행 오류를 만났을 때 취할 전환입니다.
- autoForward - (선택 사항) 이 머신에 전송된 모든 이벤트가 호출된 자식에게도 전송(또는 전달)되어야 하는 경우 true(기본적으로 false)
- ⚠️ 모든 이벤트를 맹목적으로 전달하면 예기치 않은 동작 및/또는 무한 루프가 발생할 수 있으므로 autoForward를 true로 설정하지 마십시오.
- 항상 명시적으로 이벤트를 보냅니다(send).또는, forwardTo(...) 액션 크리에이터를 사용하여 이벤트를 서비스에 직접 전달하는 것을 선호합니다.
- data - (선택 사항, 상태 기계를 호출할 때만 사용됨)
- 자식 머신 컨텍스트의 속성을 부모 머신 컨텍스트에 있는 해당 값을 반환하는 함수에 매핑하는 개체입니다.
- 예를 들어 부모에 a가 있고 자식에 a가 있으면 덮어씀
- 컨텍스트를 통째로 교환해버린다는것 같음. 확인 필요
주의사항
invoke.onDone과 혼동되는 상태에서 onDone 속성을 얻지 마십시오. 유사한 전환이지만 다른 것을 참조합니다.
- 상태 노드의 onDone 속성은 최종 상태에 도달한(종료된) 복합 상태 노드를 나타냅니다.
- invoke.onDone 속성은 수행 중인 호출(invoke.src)의 완료를 나타냅니다.
// ...
loading: {
invoke: {
src: someSrc,
onDone: {/* ... */} // refers to `someSrc` being done
},
initial: 'loadFoo',
states: {
loadFoo: {/* ... */},
loadBar: {/* ... */},
loadingComplete: { type: 'final' }
},
onDone: 'loaded' // refers to 'loading.loadingComplete' being reached
}
// ...
Invoking Promises(프로미스 호출)
모든 Promise는 상태 머신으로 모델링될 수 있으므로 XState는 Promise를 그대로 호출할 수 있습니다.
Promise는 다음 중 하나를 수행할 수 있습니다.
- resolve() : onDone 전환을 수행합니다.
- Reject()(또는 throw an erro) : onError 전환을 수행합니다.
// Function that returns a promise
// This promise might resolve with, e.g.,
// { name: 'David', location: 'Florida' }
const fetchUser = (userId) =>
fetch(`url/to/user/${userId}`).then((response) => response.json());
const userMachine = createMachine({
id: 'user',
initial: 'idle',
context: {
userId: 42,
user: undefined,
error: undefined
},
states: {
idle: {
on: {
FETCH: { target: 'loading' }
}
},
loading: {
invoke: {
id: 'getUser', //invokeId
src: (context, event) => fetchUser(context.userId), // 호출할 서비스의 소스,
onDone: {
target: 'success',
actions: assign({ user: (context, event) => event.data })
},
onError: {
target: 'failure',
actions: assign({ error: (context, event) => event.data })
}
}
},
success: {},
failure: {
on: {
RETRY: { target: 'loading' }
}
}
}
});
{
type: 'done.invoke.getUser',
data: {
name: 'David',
location: 'Florida'
}
}
Promise Rejection(프로미스 거부)
Promise가 거부되면 { type: 'error.platform' } 이벤트와 함께 onError 전환이 수행됩니다.
오류 데이터는 이벤트의 data 속성에서 사용할 수 있습니다.
const search = (context, event) => new Promise((resolve, reject) => {
if (!event.query.length) {
return reject('No query specified');
// or:
// throw new Error('No query specified');
}
return resolve(getSearchResults(event.query));
});
// ...
const searchMachine = createMachine({
id: 'search',
initial: 'idle',
context: {
results: undefined,
errorMessage: undefined,
},
states: {
idle: {
on: {
SEARCH: { target: 'searching' }
}
},
searching: {
invoke: {
id: 'search'
src: search,
onError: {
target: 'failure',
actions: assign({
errorMessage: (context, event) => {
// event is:
// { type: 'error.platform', data: 'No query specified' }
return event.data;
}
})
},
onDone: {
target: 'success',
actions: assign({ results: (_, event) => event.data })
}
}
},
success: {},
failure: {}
}
});
주의사항
onError 전환이 누락되고 Promise가 거부되면 상태 기계에 엄격 모드(strict mode)를 지정하지 않는 한 오류가 무시됩니다.
Strict 모드는 이 경우 기계를 중지하고 오류를 발생시킵니다.
Invoking Callbacks (콜백 호출하기)
- callback - 전송된 이벤트와 함께 호출됩니다.
- onReceive - 부모의 이벤트를 수신하는 리스너와 함께 호출됩니다.
// ...
counting: {
invoke: {
id: 'incInterval',
src: (context, event) => (callback, onReceive) => {
// This will send the 'INC' event to the parent every second
const id = setInterval(() => callback('INC'), 1000);
// Perform cleanup
return () => clearInterval(id);
}
},
on: {
INC: { actions: assign({ counter: context => context.counter + 1 }) }
}
}
// ...
Listening to Parent Events (부모 이벤트 리스닝하기)
const pingPongMachine = createMachine({
id: 'pinger',
initial: 'active',
states: {
active: {
invoke: {
id: 'ponger',
src: (context, event) => (callback, onReceive) => {
// Whenever parent sends 'PING',
// send parent 'PONG' event
onReceive((e) => {
if (e.type === 'PING') {
callback('PONG');
}
});
}
},
entry: send({ type: 'PING' }, { to: 'ponger' }),
on: {
PONG: { target: 'done' }
}
},
done: {
type: 'final'
}
}
});
interpret(pingPongMachine)
.onDone(() => done())
.start();
const counterInterval = (callback, receive) => {
let count = 0;
const intervalId = setInterval(() => {
callback({ type: 'COUNT.UPDATE', count });
count++;
}, 1000);
receive(event => {
if (event.type === 'INC') {
count++;
}
});
return () => { clearInterval(intervalId); }
}
Invoking Observables (옵저버블 호출)
import { createMachine, interpret } from 'xstate';
import { interval } from 'rxjs';
import { map, take } from 'rxjs/operators';
const intervalMachine = createMachine({
id: 'interval',
initial: 'counting',
context: { myInterval: 1000 },
states: {
counting: {
invoke: {
src: (context, event) =>
interval(context.myInterval).pipe(
map((value) => ({ type: 'COUNT', value })),
take(5)
),
onDone: 'finished'
},
on: {
COUNT: { actions: 'notifyCount' },
CANCEL: { target: 'finished' }
}
},
finished: {
type: 'final'
}
}
});
TIP
Observable은 모든 호출에 대해 반드시 새로 생성될 필요는 없습니다.(cold)
대신 "hot observable"을 참조할 수 있습니다.
import { fromEvent } from 'rxjs';
const mouseMove$ = fromEvent(document.body, 'mousemove');
const mouseMachine = createMachine({
id: 'mouse',
// ...
invoke: {
src: (context, event) => mouseMove$
},
on: {
mousemove: {
/* ... */
}
}
});
Invoking Machines (상태 기계를 호출)
- send(EVENT, { to: 'someChildId' }) 작업을 통한 부모-자식으로
- sendParent(EVENT) 작업을 통해 자식에서 부모로.
import { createMachine, interpret, send, sendParent } from 'xstate';
// Invoked child machine
const minuteMachine = createMachine({
id: 'timer',
initial: 'active',
states: {
active: {
after: {
60000: { target: 'finished' }
}
},
finished: { type: 'final' }
}
});
const parentMachine = createMachine({
id: 'parent',
initial: 'pending',
states: {
pending: {
invoke: {
src: minuteMachine,
// The onDone transition will be taken when the
// minuteMachine has reached its top-level final state.
onDone: 'timesUp'
}
},
timesUp: {
type: 'final'
}
}
});
const service = interpret(parentMachine)
.onTransition((state) => console.log(state.value))
.start();
// => 'pending'
// ... after 1 minute
// => 'timesUp'
Invoking with Context (context 정보와 함께 호출)
const timerMachine = createMachine({
id: 'timer',
context: {
duration: 1000 // default duration
}
/* ... */
});
const parentMachine = createMachine({
id: 'parent',
initial: 'active',
context: {
customDuration: 3000
},
states: {
active: {
invoke: {
id: 'timer',
src: timerMachine,
// Deriving child context from parent context
data: {
duration: (context, event) => context.customDuration
}
}
}
}
});
// Object (per-property):
data: {
duration: (context, event) => context.customDuration,
foo: (context, event) => event.value,
bar: 'static value'
}
// Function (aggregate), equivalent to above:
data: (context, event) => ({
duration: context.customDuration,
foo: event.value,
bar: 'static value'
})
주의사항
데이터는 시스템에 정의된 기본 컨텍스트를 대체합니다.
기존 컨텍스트와 병합되지 않습니다. 이 동작은 다음 메이저 버전에서 변경됩니다.
Done Data (완료 시 부모에게 데이터 보내기)
자식 머신이 최상위 최종 상태에 도달하면 "done" 이벤트에서 부모에 데이터를 보낼 수 있습니다
(예: { type: 'done.invoke.someId', data: ... }).
이 "완료 데이터"는 최종 상태의 데이터 속성에 지정됩니다.
const secretMachine = createMachine({
id: 'secret',
initial: 'wait',
context: {
secret: '42'
},
states: {
wait: {
after: {
1000: { target: 'reveal' }
}
},
// 최종 상태의 데이터 프로퍼티. someId에 secret
// (예: { type: 'done.invoke.someId', data: ... })
reveal: {
type: 'final',
data: {
secret: (context, event) => context.secret
}
}
}
});
const parentMachine = createMachine({
id: 'parent',
initial: 'pending',
context: {
revealedSecret: undefined
},
states: {
pending: {
invoke: {
id: 'secret',
src: secretMachine,
onDone: {
target: 'success',
actions: assign({
revealedSecret: (context, event) => {
// event is:
// { type: 'done.invoke.secret', data: { secret: '42' } }
return event.data.secret;
}
})
}
}
},
success: {
type: 'final'
}
}
});
const service = interpret(parentMachine)
.onTransition((state) => console.log(state.context))
.start();
// => { revealedSecret: undefined }
// ...
// => { revealedSecret: '42' }
Sending Events (이벤트 전송하기)
- 자식 머신에서 부모 머신으로 이벤트를 보내려면 sendParent(event)를 사용하십시오(send(...)와 동일한 인수를 사용).
- 부모 시스템에서 자식 시스템으로 보내려면 send(event, { to: <child ID> })를 사용하십시오.
주의사항
import { createMachine, interpret, send, sendParent } from 'xstate';
// Parent machine
const pingMachine = createMachine({
id: 'ping',
initial: 'active',
states: {
active: {
invoke: {
id: 'pong',
src: pongMachine
},
// Sends 'PING' event to child machine with ID 'pong'
entry: send({ type: 'PING' }, { to: 'pong' }),
on: {
PONG: {
actions: send({ type: 'PING' }, { to: 'pong', delay: 1000 })
}
}
}
}
});
// Invoked child machine
const pongMachine = createMachine({
id: 'pong',
initial: 'active',
states: {
active: {
on: {
PING: {
// Sends 'PONG' event to parent machine
actions: sendParent('PONG', {
delay: 1000
})
}
}
}
}
});
const service = interpret(pingMachine).start();
// => 'ping'
// ...
// => 'pong'
// ..
// => 'ping'
// ...
// => 'pong'
// ...
Sending Responses (응답 보내기)
호출된 서비스(또는 생성된(spawned) 액터)는 다른 서비스/액터에 응답할 수 있습니다.
즉, 다른 서비스/액터가 보낸 이벤트에 대한 응답으로 이벤트를 보낼 수 있습니다. 이것은 respond(...) 액션 생성자로 수행됩니다.
예를 들어, 아래의 '클라이언트' 시스템은 'CODE' 이벤트를 호출된 'auth-server' 서비스에 보내고
1초 후에 'TOKEN' 이벤트로 응답합니다.
import { createMachine, send, actions } from 'xstate';
const { respond } = actions;
const authServerMachine = createMachine({
id: 'server',
initial: 'waitingForCode',
states: {
waitingForCode: {
on: {
CODE: {
actions: respond('TOKEN', { delay: 1000 })
}
}
}
}
});
const authClientMachine = createMachine({
id: 'client',
initial: 'idle',
states: {
idle: {
on: {
AUTH: { target: 'authorizing' }
}
},
authorizing: {
invoke: {
id: 'auth-server',
src: authServerMachine
},
entry: send({ type: 'CODE' }, { to: 'auth-server' }),
on: {
TOKEN: { target: 'authorized' }
}
},
authorized: {
type: 'final'
}
}
});
이 특정 예제는 동일한 효과를 위해 sendParent(...)를 사용할 수 있습니다.
차이점은 respond(...)가 이벤트를 수신한 이벤트의 원점으로 되돌려 보낼 것이라는 점입니다.
이 원점은 반드시 부모 머신이 아닐 수도 있습니다.
Multiple Services (다중 서비스)
// ...
invoke: [
{ id: 'service1', src: 'someService' },
{ id: 'service2', src: 'someService' },
{ id: 'logService', src: 'logService' }
],
// ...
Configuring Services (서비스 설정하기)
호출 소스(서비스)는 src를 문자열로 지정하고
Machine 옵션의 services 속성에 정의하여 액션, 가드 등이 구성되는 방식과 유사하게 구성할 수 있습니다.
const fetchUser = // (same as the above example)
const userMachine = createMachine(
{
id: 'user',
// ...
states: {
// ...
loading: {
invoke: {
src: 'getUser',
// ...
}
},
// ...
}
},
{
services: {
getUser: (context, event) => fetchUser(context.user.id)
}
);
invoke src는 타입 및 기타 관련 메타데이터로 호출 소스를 설명하는 객체로도 지정할 수 있습니다.
meta.src 인수의 services 옵션에서 읽을 수 있습니다.
const machine = createMachine(
{
initial: 'searching',
states: {
searching: {
invoke: {
src: {
type: 'search',
endpoint: 'example.com'
}
// ...
}
// ...
}
}
},
{
services: {
search: (context, event, { src }) => {
console.log(src);
// => { endpoint: 'example.com' }
}
}
}
);
Testing
import { interpret } from 'xstate';
import { assert } from 'chai';
import { userMachine } from '../path/to/userMachine';
const mockFetchUser = async (userId) => {
// Mock however you want, but ensure that the same
// behavior and response format is used
return { name: 'Test', location: 'Anywhere' };
};
const testUserMachine = userMachine.withConfig({
services: {
getUser: (context, event) => mockFetchUser(context.id)
}
});
describe('userMachine', () => {
it('should go to the "success" state when a user is found', (done) => {
interpret(testUserMachine)
.onTransition((state) => {
if (state.matches('success')) {
assert.deepEqual(state.context.user, {
name: 'Test',
location: 'Anywhere'
});
done();
}
})
.start();
});
});
Referencing Services 4.7+
서비스(및 스폰된 서비스인 액터)는 .children 속성의 상태 개체에서 직접 참조할 수 있습니다.
state.children 객체는 서비스 ID(키)를 해당 서비스 인스턴스(값)에 매핑합니다.
const machine = createMachine({
// ...
invoke: [
{ id: 'notifier', src: createNotifier },
{ id: 'logger', src: createLogger }
]
// ...
});
const service = interpret(machine)
.onTransition((state) => {
state.children.notifier; // service from createNotifier()
state.children.logger; // service from createLogger()
})
.start();
Quick Reference
The invoke property
const machine = createMachine({
// ...
states: {
someState: {
invoke: {
// The `src` property can be:
// - a string
// - a machine
// - a function that returns...
src: (context, event) => {
// - a promise
// - a callback handler
// - an observable
},
id: 'some-id',
// (optional) forward machine events to invoked service (currently for machines only!)
autoForward: true,
// (optional) the transition when the invoked promise/observable/machine is done
onDone: { target: /* ... */ },
// (optional) the transition when an error from the invoked service occurs
onError: { target: /* ... */ }
}
}
}
});
Invoking Promises
// Function that returns a promise
const getDataFromAPI = () => fetch(/* ... */)
.then(data => data.json());
// ...
{
invoke: (context, event) => getDataFromAPI,
// resolved promise
onDone: {
target: 'success',
// resolved promise data is on event.data property
actions: (context, event) => console.log(event.data)
},
// rejected promise
onError: {
target: 'failure',
// rejected promise data is on event.data property
actions: (context, event) => console.log(event.data)
}
}
// ...
Invoking Callbacks
// ...
{
invoke: (context, event) => (callback, onReceive) => {
// Send event back to parent
callback({ type: 'SOME_EVENT' });
// Receive events from parent
onReceive(event => {
if (event.type === 'DO_SOMETHING') {
// ...
}
});
},
// Error from callback
onError: {
target: 'failure',
// Error data is on event.data property
actions: (context, event) => console.log(event.data)
}
},
on: {
SOME_EVENT: { /* ... */ }
}
Invoking Observables
import { map } from 'rxjs/operators';
// ...
{
invoke: {
src: (context, event) => createSomeObservable(/* ... */).pipe(
map(value => ({ type: 'SOME_EVENT', value }))
),
onDone: 'finished'
}
},
on: {
SOME_EVENT: /* ... */
}
// ...
Invoking Machines
const someMachine = createMachine({ /* ... */ });
// ...
{
invoke: {
src: someMachine,
onDone: {
target: 'finished',
actions: (context, event) => {
// Child machine's done data (.data property of its final state)
console.log(event.data);
}
}
}
}
// ...
'FrontEnd' 카테고리의 다른 글
XState의 Effect(부수 효과) - Action (0) | 2022.05.04 |
---|---|
XState의 Actor 알아보기 (0) | 2022.05.04 |
XState와 비동기 2편: XState Actor와 함께 비동기를 안전하게 모델링 (0) | 2022.05.01 |
XState와 비동기 1편: useEffect 안의 비동기 코드는 위험합니다! (0) | 2022.04.30 |
XState : 상태 머신과 상태차트 소개 (0) | 2022.04.30 |