액터는 내부 상태를 보존하고 나중에 복원할 수 있다. 지속성(Persistence)은 액터의 상태를 로컬스토리지나 데이터베이스 같은 영구 저장소에 저장하는 것을 말한다. 복원(Restoration)은 영구 저장소에서 액터의 상태를 복원하는 것을 말한다.
지속성은 브라우저를 다시 로드하는 동안 상태를 유지하는 데 유용하다. 백엔드 어플리케이션에서 지속성은 워크플로우가 여러 요청에 걸쳐있고, 서비스 재시작에도 살아남고, 내결함성, 장기실행프로세스를 할 수 있고, autiable과 traceable하게 해준다.
XState에서는 actor.getPersistedSnapshot()
를 통해서 스냅샷을 가져와 createActor(behavior, { snapshot: restoredState }).start()
를 통해 복원할 수 있다.
const feedbackActor = createActor(feedbackMachine).start();
// Get state to be persisted
const persistedState = feedbackActor.getPersistedSnapshot();
// Persist state
localStorage.setItem("feedback", JSON.stringify(persistedState));
// Restore state
const restoredState = JSON.parse(localStorage.getItem("feedback"));
const restoredFeedbackActor = createActor(feedbackMachine, {
snapshot: restoredState,
}).start();
actor.getPersistedSnapshot()
를 사용해서 보존되는 상태를 얻을 수 있다.
const feedbackActor = createActor(feedbackMachine).start();
// Get state to be persisted
const persistedState = feedbackActor.getPersistedSnapshot();
내부 상태는 머신뿐만 아니라 모든 액터에서 지속될 수 있다. 지속 상태는 액터의 내부 상태를 나타내는 반면, 스냅샷은 액터가 마지막으로 방출한 값을 나타내므로 actor.getSnapshot()
의 스냅샷과 동일하지 않다.
const promiseActor = fromPromise(() => Promise.resolve(42));
// Get the last emitted value
const snapshot = promiseActor.getSnapshot();
console.log(snapshot);
// logs 42
// Get the persisted state
const persistedState = promiseActor.getPersistedSnapshot();
console.log(persistedState);
// logs { status: 'done', data: 42 }
createActor(logic, { snapshot: restoredState })
의 두번째 인수의 state 옵션에 지속 상태를 전달하여 지속 상태로 복원할 수 있다.
// Get persisted state
const restoredState = JSON.parse(localStorage.getItem("feedback"));
// Restore state
const feedbackActor = createActor(feedbackMachine, {
snapshot: restoredState,
});
feedbackActor.start();
머신 액터의 액션의 이미 실행된 것으로 간주되므로 다시 실행되지 않는다. 그러나 호출은 다시 시작되고 스폰된 액터는 재귀적으로 복원된다.
머신 액터에서 state 지속 & 복원은 깊어서, 모든 호출, 스폰된 액터는 재귀적으로 지속, 복원된다.
const feedbackMachine = createMachine({
// ...
states: {
form: {
invoke: {
id: "form",
src: formMachine,
},
},
},
});
const feedbackActor = createActor(feedbackMachine).start();
// Persist state
const persistedState = feedbackActor.getPersistedSnapshot();
localStorage.setItem("feedback", JSON.stringify(persistedState));
// ...
// Restore state
const restoredState = JSON.parse(localStorage.getItem("feedback"));
const restoredFeedbackActor = createActor(feedbackMachine, {
snapshot: restoredState,
}).start();
// Will restore both the feedbackActor and the invoked form actor at
// their persisted states
상태머신 액터의 유한 상태 값만 유지하려면 machine.resolveState(...)
메서드를 사용할 수 있다.
import { someMachine } from "./someMachine";
const restoredStateValue = localStorage.getItem("someState");
// Assume that this is "pending"
const resolvedState = someMachine.resolveState({
value: restoredStateValue,
// context: { ... }
});
// Restore the actor
const restoredActor = createActor(someMachine, {
snapshot: resolvedState,
});
restoredActor.start();
상태 지속의 대안으로 이벤트 소싱이란 것이 있는데, 이는 해당 상태를 일으킨 이벤트를 재생하여 액터의 상태를 복원하는 방법이다. 이벤트 소싱은 호환되지 않는 상태가 발생할 가능성이 적고, 동작을 재생할 수 있기 때문에 상태 지속보다 더 안정적일 수 있다.
이벤트 소싱을 구현하는 한 가지 방법은 검사 API를 사용하여 이벤트가 발생할 때 이벤트를 지속한 다음, 이를 재생하여 액터의 상태를 복원하는 것이다.
const events = [];
const someActor = createActor(someMachine, {
// Inspect and persist events
inspect: (inspectionEvent) => {
if (inspectionEvent.type === "@xstate.event") {
const event = inspectionEvent.event;
// Only listen for events sent to the root actor
if (inspectionEvent.actorRef !== someActor) {
return;
}
events.push(event);
}
},
});
someActor.start();
// ...
// Assuming the events are stored somewhere, e.g. in localStorage,
// you can replay them to restore the state of the actor
const restoredActor = createActor(someMachine);
restoredActor.start();
for (const event of events) {
// Replay events
restoredActor.send(event);
}
상태를 유지하고 복원할 때 주의해야할 몇 가지 주의 사항이 있다:
state machine이 시ㄱ되면, initial state로 먼저 진입한다. 머신은 최상위에 오직 하나의 initial state를 가질 수 있다; 만약 여러개의 초기 상태들이 있다면, 머신은 어디서 시작해야할지 알 수 없을 것이다.
XState에서는 초기 상태는 initial
프로퍼티로 인해 정의된다.
const feedbackMachine = createMachine({
id: "feedback",
// Initial state
initial: "prompt",
// Finite states
states: {
prompt: {
/* ... */
},
// ...
},
});
전형적으로 상태머신은 다수의 유한한 상태들(finite states)이 있을 수 있다. 머신의 initial
프로퍼티는 머신이 시작해야하는 초기 상태를 지정한다.
부모 상태 또한 initial
프로퍼티에 초기 상태를 지정해야한다.
유한한 상태는 상태머신이 주어진 시간안에 있을 수 있는 가능한 상태 중 하나이다. 상태머신은 가능한 상태의 수가 제한되어 있기 때문에 이를 “finite” 유한이라고 한다. 상태는 머신이 해당 상태에 있을 때 어떻게 “동작”하는지를 나타낸다; 이것은 그것의 status(상태) 또는 모드를 의미한다.
예를 들어 피드백 양식의 경우 양식을 작성 중인 상태이거나 양식을 제출 중인 상태일 수 있다. 양식을 작성하는 동시에 제출할 수는 없으며, 이는 “불가능한 상태”이다.
상태머신은 항상 초기 상태에서 시작하고, final state(마지막 상태)에서 끝난다. 상태머신은 항상 유한한 상태에 있다.
const feedbackMachine = createMachine({
id: "feedback",
// Initial state
initial: "prompt",
// Finite states
states: {
prompt: {
/* ... */
},
form: {
/* ... */
},
thanks: {
/* ... */
},
closed: {
/* ... */
},
},
});
유한 상태와 컨텍스트를 결합하여 머신의 전체 상태를 구성할 수 있따.
const feedbackMachine = createMachine({
id: "feedback",
context: {
name: "",
email: "",
feedback: "",
},
initial: "prompt",
states: {
prompt: {
/* ... */
},
},
});
const feedbackActor = createActor(feedbackMachine).start();
// Finite state
console.log(feedbackActor.getSnapshot().value);
// logs 'prompt'
// Context ("extended state")
console.log(feedbackActor.getSnapshot().context);
// logs { name: '', email: '', feedback: '' }
초기 상태는 머신이 시작할 떄의 상태이다. 이것은 initial 프로퍼티를 통해 정의된다.
XState에서 상태 노드는 전체 statechart 트리를 구성하는 유한 상태 “노드”이다. 상태노드는 루트 머신 config에 포함한 다른 상태 노드의 states 프로퍼티에 정의된다.
// The machine is the root state node
const feedbackMachine = createMachine({
id: "feedback",
initial: "prompt",
// State nodes
states: {
// State node
prompt: {
/* ... */
},
// State node
form: {
/* ... */
},
// State node
thanks: {
/* ... */
},
// State node
closed: {
/* ... */
},
},
});
상태노드는 tags를 가질 수 있다. tags는 상태노드를 그룹화하거나 카테고라이징하는데 도움이 되는 문자열 용어이다.
예를 들면, loading 태그를 사용하여 데이터가 로드 중인 상태를 나타내는 상태노드를 표시하고, state.hasTag(tag)
를 사용하여 해당 태그가 지정된 상태 노드가 상태에 포함되어 있는지 확인할 수 있다.
const feedbackMachine = createMachine({
id: 'feedback',
initial: 'prompt',
states: {
prompt: {
tags: ['visible'],
// ...
},
form: {
tags: ['visible'],
// ...
},
thanks: {
tags: ['visible', 'confetti'],
// ...
},
closed: {
tags: ['hidden'],
},
},
});
const feedbackActor = createActor(feedbackMachine).start();
console.log(feedbackActor..getSnapshot().hasTag('visible'));
// logs true
메타데이터는 상태노드의 관련 속성을 설명하는 스태틱 데이터이다. 상태노드의 .meta
프로퍼티에서 메타데이터를 지정할 수 있다. 이는 UI에서 상태노드에 대한 정보를 표시하거나 문서화를 하는데 유용할 수 있다.
state.meta
프로퍼티는 모든 활성 상태노드에서 .meta
데이터를 수집하여 상태노드의 ID를 key로, 메타데이터를 값으로 사용하여 객체로 배치한다.
const feedbackMachine = createMachine({
id: "feedback",
initial: "prompt",
meta: {
title: "Feedback",
},
states: {
prompt: {
meta: {
content: "How was your experience?",
},
},
form: {
meta: {
content: "Please fill out the form below.",
},
},
thanks: {
meta: {
content: "Thank you for your feedback!",
},
},
closed: {},
},
});
const feedbackActor = createActor(feedbackMachine).start();
console.log(feedbackActor.getSnapshot().meta);
// logs the object:
// {
// feedback: {
// title: 'Feedback',
// },
// 'feedback.prompt': {
// content: 'How was your experience?',
// }
// }
트랜지션은 유한 상태에서 다른 상태로 어떻게 이동하는지를 의미한다. on 프로퍼티로 정의할 수 있다.
트랜지션의 target 프로퍼티는 머신이 트랜지션이 일어났을 때 어디로 가야할지 지정해주는 곳이다.
상태는 유니크한 ID로 식별할 수 있다: id: 'myState'
이는 부모 상태가 다른 경우에도 다른 상태의 상태를 타겟팅하는게 유용하다:
import { createMachine, createActor } from "xstate";
const feedbackMachine = createMachine({
initial: "prompt",
states: {
// ...
closed: {
id: "finished",
type: "final",
},
// ...
},
on: {
"feedback.close": {
// Target the `.closed` state by its ID
target: "#finished",
},
},
});
액션은 실행 후 잊어버리는 효과(fire-and-forget effect)이다. 상태머신이 트랜지션될 때, 액션을 실행할 수 있다. 액션은 이벤트에 대한 응답으로 발생하며, 일반적으로 actions: [...]
프로퍼티로 정의된다. state에서 기재될 수 있는 모든 트랜지션에 대해 액션을 정의할 수 있다. 시작, 종료 프로퍼티에도 가능하다.
import { setup } from 'xstate';
const feedbackMachine = setup({
actions: {
track: (_, params: unknown) => {
track(params);
// Tracks { response: 'good' }
},
showConfetti: () => {
// ...
}
}
}).createMachine({
// ...
states: {
// ...
question: {
on: {
'feedback.good': {
actions: [
{ type: 'track', params: { response: 'good' } }
]
}
},
exit: ['exitAction']
}
thanks: {
entry: ['showConfetti'],
}
}
});
actions 예시:
액션 객체는 type과 선택적인 params를 가진다:
import { setup } from "xstate";
const feedbackMachine = setup({
actions: {
track: (_, params: unknown) => {
/* ... */
},
},
}).createMachine({
// ...
states: {
// ...
question: {
on: {
"feedback.good": {
actions: [
{
// Action type
type: "track",
// Action params
params: { response: "good" },
},
],
},
},
},
},
});
params를 리턴하는 함수를 사용하여 동적으로 파라미터값을 params
프로퍼티에 전달할 수 있다. 이 함수는 context와 event를 가지고 있는 객체를 인자로 가진다.
import { setup } from "xstate";
const feedbackMachine = setup({
actions: {
logInitialRating: (_, params: { initialRating: number }) => {
// ...
},
},
}).createMachine({
context: {
initialRating: 3,
},
entry: [
{
type: "logInitialRating",
params: ({ context }) => ({
initialRating: context.initialRating,
}),
},
],
});
아래 방식은 재사용이 가능하고 머신의 context나 event 타입에 의존하지 않는 정의방법으로 권장된다.
import { setup } from "xstate";
function logInitialRating(_, params: { initialRating: number }) {
console.log(`Initial rating: ${params.initialRating}`);
}
const feedbackMachine = setup({
actions: { logInitialRating },
}).createMachine({
context: { initialRating: 3 },
entry: [
{
type: "logInitialRating",
params: ({ context }) => ({
initialRating: context.initialRating,
}),
},
],
});
import { createMachine } from "xstate";
const feedbackMachine = createMachine({
entry: [
// Inline action
({ context, event }) => {
console.log(/* ... */);
},
],
});
액션 객체를 사용하는 것이 권장된다.
// 방법 1
import { setup } from "xstate";
const feedbackMachine = setup({
actions: {
track: ({ context, event }, params) => {
// Action implementation
// ...
},
},
}).createMachine({
// Machine config
entry: [{ type: "track", params: { msg: "entered" } }],
});
// 방법 2
const feedbackActor = createActor(
feedbackMachine.provide({
actions: {
track: ({ context, event }, params) => {
// Different action implementation
// (overrides previous implementation)
// ...
},
},
})
);
assign()
액션은 context에 데이터를 할당하는 특수한 액션이다. assign(assignment)
의 assignments 인수는 context에 대한 할당이 지정되는 곳이다.
import { setup } from "xstate";
const countMachine = setup({
types: {
events: {} as { type: "increment"; value: number },
},
}).createMachine({
context: {
count: 0,
},
on: {
increment: {
actions: assign({
count: ({ context, event }) => context.count + event.value,
}),
},
},
});
const countActor = createActor(countMachine);
countActor.subscribe((state) => {
console.log(state.context.count);
});
countActor.start();
// logs 0
countActor.send({ type: "increment", value: 3 });
// logs 3
countActor.send({ type: "increment", value: 2 });
// logs 5
raise 액션은 동일한 머신에서 받아온 이벤트를 발생하는 특수한 액션이다. 이벤트 발생은 머신이 스스로에게 이벤트를 ‘send’할 수 있는 방법이다:
import { createMachine, raise } from 'xstate';
const machine = createMachine({
// ...
entry: raise({ type: 'someEvent', data: 'someData' });
});
내부적으로 이벤트다 raise되면, internal event queue 내부 이벤트 대기열에 배치된다. 현재 트랜지션이 완료된 이후에 이 이벤트들은 FIFO 순서대로 처리된다. 외부 이벤트는 내부 이벤트 큐의 모든 이벤트가 처리된 이후에만 처리된다.
raised 이벤트는 동적일 수도 있다:
import { createMachine, raise } from "xstate";
const machine = createMachine({
// ...
entry: raise(({ context, event }) => ({
type: "dynamicEvent",
data: context.someValue,
})),
});
delay로도 사용될 수 있으며, 내부 이벤트 대기열에 넣지 않고 delay시켜서 발생시킬 수도 있다.
sendTo(...)
액션은 특정 액터에게 이벤트를 보내는 특수한 액션이다.
const machine = createMachine({
on: {
transmit: {
actions: sendTo("someActor", { type: "someEvent" }),
},
},
});
// dynamic
const machine = createMachine({
on: {
transmit: {
actions: sendTo("someActor", ({ context, event }) => {
return { type: "someEvent", data: context.someData };
}),
},
},
});
// 도착지 actor는 actor ID 또는 스스로를 참조하는 actor가 될 수 있다.
const machine = createMachine({
context: ({ spawn }) => ({
someActorRef: spawn(fromPromise(/* ... */)),
}),
on: {
transmit: {
actions: sendTo(({ context }) => context.someActorRef, {
type: "someEvent",
}),
},
},
});
// delay와 id 같은 다른 옵션을 세번째 인자에 전달할 수 있다.
const machine = createMachine({
on: {
transmit: {
actions: sendTo(
"someActor",
{ type: "someEvent" },
{
id: "transmission",
delay: 1000,
}
),
},
},
});
enqueueActions(...)
액션 생성자는 액션을 실제로 실행하지 않고 순차적으로 실행할 액션을 대기열에 추가하는 상위 레벨 액션이다. 이 함수는 context, event를 콜백으로 받을 수 있고, 더해서 enqueue, check 함수를 수신할 수 있다:
enqueue(...)
함수는 액션을 대기열에 추가하는 데에 사용된다. 이 함수는 액션 객체 또는 액션 함수를 받는다:actions: enqueueActions(({ enqueue }) => {
// Enqueue an action object
enqueue({ type: "greet", params: { message: "hi" } });
// Enqueue an action function
enqueue(() => console.log("Hello"));
// Enqueue a simple action with no params
enqueue("doSomething");
});
check(...)
함수는 조건적으로 액션을 대기열에 추가하는 데에 사용된다. guard 객체 또는 guard 함수를 받아서 이 가드가 true인지 여부를 나타내는 boolean을 리턴한다.actions: enqueueActions(({ enqueue, check }) => {
if (check({ type: "everythingLooksGood" })) {
enqueue("doSomething");
}
});
enqueue
에 있다:enqueue.assign()
enqueue.sendTo()
enqueue.raise()
enqueue.spawnChild()
enqueue.stopChild()
enqueue.cancel()
대기열에 추가된 액션은 조건부로 호출할 수 있지만, 비동기적으로 대기열에 추가할 수 없다.
const machine = createMachine({
// ...
entry: enqueueActions(({ context, event, enqueue, check }) => {
// assign action
enqueue.assign({
count: context.count + 1,
});
// Conditional actions (replaces choose(...))
if (event.someOption) {
enqueue.sendTo("someActor", { type: "blah", thing: context.thing });
// other actions
enqueue("namedAction");
// with params
enqueue({ type: "greet", params: { message: "hello" } });
} else {
// inline
enqueue(() => console.log("hello"));
// even built-in actions
}
// Use check(...) to conditionally enqueue actions based on a guard
if (check({ type: "someGuard" })) {
// ...
}
// no return
}),
});
log(...)
액션은 콘솔에 로그메세지를 간단하게 남길 수 있다.
import { createMachine, log } from "xstate";
const machine = createMachine({
on: {
someEvent: {
actions: log("some message"),
},
},
});
cancel(...)
액션은 지연된 sentTo(...)
또는 raise(...)
액션을 그들의 IDs로 취소한다.
import { createMachine, sendTo, cancel } from "xstate";
const machine = createMachine({
on: {
event: {
actions: sendTo(
"someActor",
{ type: "someEvent" },
{
id: "someId",
delay: 1000,
}
),
},
cancelEvent: {
actions: cancel("someId"),
},
},
});
stopChild(...)
액션은 자식 액터를 멈춘다. 액터는 그들의 부모 액터를 통해서 멈출 수 있다:
import { createMachine, stopChild } from "xstate";
const machine = createMachine({
context: ({ spawn }) => ({
spawnedRef: spawn(fromPromise(/* ... */), { id: "spawnedId" }),
}),
on: {
stopById: {
actions: stopChild("spawnedId"),
},
stopByRef: {
actions: stopChild(({ context }) => context.spawnedRef),
},
},
});
이벤트에 대한 응답으로 액션이 실행해야하는 경우, 액션만 있는 self-transition을 만들 수 있다.
import { createMachine } from "xstate";
const countMachine = createMachine({
context: {
count: 0,
},
on: {
increment: {
actions: assign({
count: ({ context, event }) => context.count + event.value,
}),
},
decrement: {
actions: assign({
count: ({ context, event }) => context.count - event.value,
}),
},
},
});
Delayed transitions은 설정된 시간 후에만 발생하는 트랜지션이다. 지연된 트랜지션은 앱 로직에 타임아웃과 intervals를 설정하는 데 유용하다. 타이머가 끝나기 전에 다른 이벤트가 발생하면 트랜지션이 완료되지 않는다.
지연된 트랜지션은 after 속성에 밀리초 단위로 정의된다. after 트랜지션이라고도 한다.
import { createMachine } from "xstate";
const pushTheButtonGame = createMachine({
initial: "waitingForButtonPush",
states: {
waitingForButtonPush: {
after: {
5000: {
target: "timedOut",
actions: "logThatYouGotTimedOut",
},
},
on: {
PUSH_BUTTON: {
actions: "logSuccess",
target: "success",
},
},
},
success: {},
timedOut: {},
},
});
인라인, 참조, 표현식 등 몇 가지 방법으로 delays를 정의할 수 있다.
const machine = createMachine({
initial: "idle",
states: {
idle: {
after: {
1000: { target: "nextState" },
},
},
nextState: {},
},
});
1000밀리초 이후에 nextState 상태로 트랜지션된다.
문자열 delay 키를 지정하고 실제 지연 시간을 별도로 제공하여 참조 delay를 정의할 수도 있다.
import { setup } from "xstate";
const machine = setup({
delays: {
timeout: 1000,
},
}).createMachine({
initial: "idle",
states: {
idle: {
after: {
timeout: { target: "nextState" },
},
},
nextState: {},
},
});
delayed transition 타이머는 state에서 exited되면 취소된다.
]]>가드는 머신이 이벤트를 통과할 때 확인하는 조건 함수이다. true이면 다음 state로의 트랜지션을 진행하고, false이면 나머지 조건에 따라 다음 state로 넘어간다.
가드된 트랜지션은 가드가 true로 평가되는 경우에만 활성화되는 트랜지션이다. 가드는 트랜지션 활성화 여부를 결정한다. 모든 트랜지션이 가드된 트랜지션이 될 수 있다.
가드는 true 또는 false를 리턴하는 순수 동기함수여야한다.
const feedbackMachine = createMachine(
{
// ...
states: {
form: {
on: {
"feedback.submit": {
guard: "isValid",
target: "submitting",
},
},
},
submitting: {
// ...
},
},
},
{
guards: {
isValid: ({ context }) => {
return context.feedback.length > 0;
},
},
}
);
특정 상황에서 단일 이벤트가 다른 state로 트랜지션되도록 하려면, 가드된 트랜지셙 배열을 제공할 수 있다. 각 트랜지션은 순서대로 테스트되고, 가드가 true로 평가되는 첫 번째 트랜지션이 사용된다.
배열의 마지막 트랜지션으로 사용할 기본 트랜지션을 지정할 수 있다. 가드 중 어느 것도 true로 평가되지 않으면 기본 트랜지션이 사용된다.
const feedbackMachine = createMachine({
// ...
prompt: {
on: {
"feedback.provide": [
// Taken if 'sentimentGood' guard evaluates to `true`
{
guard: "sentimentGood",
target: "thanks",
},
// Taken if none of the above guarded transitions are taken
// and if 'sentimentBad' guard evaluates to `true`
{
guard: "sentimentBad",
target: "form",
},
// Default transition
{ target: "form" },
],
},
},
});
가드를 인라인 함수로 정의할 수 있다. 하지만 직렬화된 가드를 사용하는 것이 권장된다.
on: {
event: {
guard: ({ context, event }) => true,
target: 'someState'
}
}
가드는 제공된 가드 구현을 참조하는 가드 타입인 type과 선택적 매개변수인 params를 가진 객체로 정의할 수 있다:
const feedbackMachine = createMachine(
{
// ...
states: {
// ...
form: {
on: {
submit: {
guard: { type: "isValid", params: { maxLength: 50 } },
target: "submitting",
},
},
},
// ...
},
},
{
guards: {
isValid: ({ context }, params) => {
return (
context.feedback.length > 0 &&
context.feedback.length <= params.maxLength
);
},
},
}
);
const feedbackActor = createActor(
feedbackMachine.provide({
guards: {
isValid: ({ context }, params) => {
return (
context.feedback.length > 0 &&
context.feedback.length <= params.maxLength &&
isNotSpam(context.feedback)
);
},
},
})
).start();
XState는 다른 가드를 구성하는 상위 레벨 가드를 제공한다. 상위 레벨 가드에는 3가지가 있다:
and([...])
or([...])
not(...)
on: {
event: {
guard: and(["isValid", "isAuthorized"]);
}
}
on: {
event: {
guard: and(["isValid", or(["isAuthorized", "isGuest"])]);
}
}
stateIn(stateValue)
가드를 사용하여 현재 state가 제공된 stateValue와 일치하는지 확인할 수 있다. parallel states에 유용하다.
on: {
event: {
guard: stateIn('#state1');
},
anotherEvent: {
guard: stateIn({ form: 'submitting' })
}
}
In-state 가드는 상태 노드가 아닌 전체 머신의 상태와 일치한다. 일반적으로는 일반 state에서 in-state 가드를 사용할 필요는 없다. 상태머신에서 트랜지션을 모델링하여 in-state 가드를 사용할 필요가 없도록 하는게 좋다.
]]>Eventless transitions(이벤트 없는 전환)은 명시적인 이벤트 없이 발생하는 전환이다. 이러한 트랜지션은 트랜지션이 활성화되어있을 때 항상 수행된다.
이벤트 없는 트랜지션은 always
state 프로퍼티에 지정되며, 흔히 always 트랜지션이라고 한다.
import { createMachine } from "xstate";
const machine = createMachine({
states: {
form: {
initial: "valid",
states: {
valid: {},
invalid: {},
},
always: {
guard: "isValid",
target: "valid",
},
},
},
});
이벤트 없는 트랜지션은 일반 트랜지션이 수행된 직후에 수행된다. 예를 들어 guard가 참인 경우와 같이 활성화된 경우에만 수행된다. 이벤트 없는 트랜지션은 특정 조건이 참일 때 작업을 수행하는데에 더 유용하다.
always 트랜지션은 항상 실행되므로 무한 루프를 만들지 않도록 주의해야 한다. XState는 대부분의 무한 루프 시나리오를 방지하는데 도움이 된다.
target이나 guard가 없는 이벤트 없는 트랜지션은 무한 루프를 유발한다. 가드와 액션을 사용하는 트랜지션은 가드가 계속 참으로 반환되면 무한 루프가 발생할 수 있다.
이벤트없는 트랜지션은 다음을 사용하여 정의해야한다:
target가 선언된 경우 값은 현재 상태 노드와 달라야한다.
state 변경이 필요하지만 특정 트리거가 없는 경우에 이벤트 없는 전환이 유용할 수 있다.
import { createMachine } from "xstate";
const machine = createMachine({
id: "kettle",
initial: "lukewarm",
context: {
temperature: 80,
},
states: {
lukewarm: {
on: {
boil: { target: "heating" },
},
},
heating: {
always: {
guard: ({ context }) => context.temperature > 100,
target: "boiling",
},
},
boiling: {
entry: ["turnOffLight"],
always: {
guard: ({ context }) => context.temperature <= 100,
target: "heating",
},
},
},
on: {
"temp.update": {
actions: ["updateTemperature"],
},
},
});
이벤트 없는 트랜지션은 모든 이벤트에 의해 잠재적으로 활성화될 수 있으므로 이벤트 타입은 모든 가능한 이벤트의 union이다.
]]>리액트는 fetchAPI를 확장하여 URL과 옵션이 동일한 요청을 자동으로 메모화도록 한다. 즉, 리액트 컴포넌트 트리의 여러 위치에서 동일한 데이터에 대한 fetch 함수를 한 번만 실행하면서 호출할 수 있다.
async function getItem() {
// The `fetch` function is automatically memoized and the result
// is cached
const res = await fetch("https://.../item/1");
return res.json();
}
// This function is called twice, but only executed the first time
const item = await getItem(); // cache MISS
// The second call could be anywhere in your route
const item = await getItem(); // cache HIT
캐시의 지속성
캐시는 리액트 컴포넌트 트리가 렌더링을 완료할 때까지 서버 request 수명동안 지속된다. 현재 페이지에서 렌더링 되는 것까지만 request 캐시 지속성이 있다.
캐시의 유효성
메모화가 서버 요청 간에 공유되지 않고 렌더링 중에만 적용되므로, 다시 유효성을 검사할 필요가 없다.
어떤 시간대가 지나면 갱신하기
// Revalidate at most every hour
fetch("https://...", { next: { revalidate: 3600 } });
직접 캐시 갱신하기, revalidateTag
활용하기
메모화를 선택 해제하려면 request에서 AbortController
신호를 전달한다.
// Opt out of caching for an individual `fetch` request
fetch(`https://...`, { cache: "no-store" });
빌드 시점에 자동으로 라우트를 렌더링하고 캐시한다. 모든 요청에 대해 서버에서 렌더링하는 대신 캐시된 경로를 제공하여 페이지 로딩 속도를 높일 수 있도록한다.
서버에서 nextjs는 react api를 사용하여 렌더링을 오케스트레이션한다. 렌더링 작업은 개별 라우트 세그먼트와 suspense 바운더리에 따라 청크로 나뉘어진다.
풀라우트 캐시는 정적인 페이지인 경우에 유용하다. 정적인 페이지가 아닌경우, 즉 쿠키를 쓰거나, 헤더, 서치파람 같이 자주 바뀌는 경우, 그때마다 새로 그려서 캐시를 업데이트해줘야하기 때문에 의미가 없음.
사용자 세션이 진행되는 동안, 개별 라우트 세그먼트로 분할된 리액트 서버 컴포넌트 페이로드를 저장하는 인메모리 클라이언트 캐시가 있다.
사용자가 라우트를 네비게이트할 때, nextjs는 방문한 라우트 세그먼트를 캐시하고 사용자가 네비게이팅 가능성이 높은 라우터를 미리 가져온다.
캐시는 브라우저의 임시 메모리에 저장된다. 라우터 캐시의 지속시간은 두 가지 요인에 의해 결정된다.
라우터 캐시를 무효화하는 방법은 두 가지가 있다.
Server Action
revalidatePath
), 또는 캐시 태그(revalidateTag
)를 기준으로 수동으로 재검증하기cookies.set
또는 cookies.delete
를 사용하면 라우터 캐시가 무효화되어 쿠키를 사용하는 라우터가 캐시로 인해 잘못된 쿠키가 사용되지 않게끔 방지한다.router.refresh
를 호출하면 라우터 캐시가 무효화되고 현재 라우터에 대한 새 요청이 서버에 전송된다.
라우터 캐시를 끄는 것은 불가능하다. 하지만 router.refresh
, revalidatePath
, revalidateTag
를 호출하여 이를 무효화할 수 있다.(invalidate) 이렇게하면 라우터 캐시가 지워지고 서버에 새로 요청하여 최신 데이터가 표시되게 된다.
mutation을 수행하기 전에 상태를 optimistically 업데이트하면 mutation이 실패할 가능성이 있다. 이러한 실패 사례 대부분은 optimistic 쿼리에 대해 리페치를 트리거하여 실제 서버 상태로 되돌릴 수 있다. 리페칭이 불가능한 서버 문제가 있을 경우에는 제대로 작동하지 않을 수 있다. 이 경우에는 업데이트를 롤백하도록 선택할 수 있다.
이를 하려면 useMutation
의 onMutate
핸들러 옵션을 사용하면 나중에 onError
onSettled
핸들러에 모두 전달될 값을 마지막 인수로 반환할 수 있다. 주로 롤백함수를 전달하는 것이 유용하다.
const queryClient = useQueryClient();
useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
// 다른 refetch 취소
// (optimistic update를 덮어씌우지 않도록 하기 위함)
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 이전 값들 스냅샷
const previousTodos = queryClient.getQueryData(["todos"]);
// 새로운 값 Optimistically update
queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);
// 스냅샷한 값들로 context 객체 리턴
return { previousTodos };
},
// mutation 실패시
// context 사용하여 이전 결과로 롤백
onError: (err, newTodo, context) => {
queryClient.setQueryData(["todos"], context.previousTodos);
},
// error나 success시 항상 refetch
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
useInfiniteQuery 훅을 사용하여 다음 데이터들을 불러올 수가 있다.
참고: useInfiniteQuery
언제 업데이트 될 것인지를 정의해주는 훅스
어느 시점에서 useInView로 만든 컴포넌트가 보이면 그 순간 업데이트가 되도록 useEffect로 정의할 수 있다.
]]>server actions은 서버에서 실행되는 비동기함수이다. 서버와 클라이언트 컴포넌트에서 폼 양식 제출 및 mutations을 사용될 수 있다.
Server Action은 "use server"
라는 리액트 디렉티브를 정의할 수 있다. 디렉티브를 비동기함수 상단에 두어 해당 함수를 서버 액션으로 표시하거나 별도의 파일 상단에 배치하여 해당 파일을 서버 액션으로 표시할 수 있다.
서버 컴포넌트는 "use server"
인라인 함수 레벨이나 모듈 레벨에서 사용할 수 있다. 서버 액션을 인라인하기 위해서는 함수 본문 상단에 디렉티브를 추가한다.
// Server Component
export default function Page() {
// Server Action
async function create() {
'use server'
// ...
}
return (
// ...
)
}
클라이언트 컴포넌트에서는 module-level로 "use server"
디렉티브를 넣은 actions만 임포트할 수 있다.
서버액션을 클라이언트 컴포넌트에서 호출하려면, 새로운 파일을 생성하고 "use server"
디렉티브를 상단에 추가한다. 그러면 파일의 모든 함수들은 클라이언트과 서버 컴포넌트 모두에서 재사용되는 서버액션이 된다.
// app/actions.ts
"use server";
export async function create() {
// ...
}
// app/ui/button.tsx
import { create } from '@/app/actions'
export function Button() {
return (
// ...
)
}
클라이언트 컴포넌트의 prop으로도 서버액션을 전달할 수 있다:
<ClientComponent updateItem={updateItem} />;
// ClientComponent
("use client");
export default function ClientComponent({ updateItem }) {
return <form action={updateItem}>{/* ... */}</form>;
}
<form>
뿐만이 아니라 이벤트핸들러, useEffect
, 써드파티 라이브러리들과 다른 <button>
같은 기타 form elements에서도 호출이 가능하다.React에서는 HTML <form>
요소를 확장하여 action
이라는 prop으로 서버액션을 호출할 수 있도록 한다.
form이 호출될 때, 액션은 자동적으로 FormData
객체를 받는다. 해당 필드들을 관리하기위해 useState를 쓰는 대신, FormData
메소드를 사용해서 data를 추출한다.
export default function Page() {
async function createInvoice(formData: FormData) {
"use server";
const rawFormData = {
customerId: formData.get("customerId"),
amount: formData.get("amount"),
status: formData.get("status"),
};
// mutate data
// revalidate cache
}
return <form action={createInvoice}>...</form>;
}
자세한건 React <form>
documentation 참고
저바스크립트의 bind
메소드를 사용해서 서버액션에 추가 인자를 전달할 수 있다.
"use client";
import { updateUser } from "./actions";
export function UserProfile({ userId }: { userId: string }) {
const updateUserWithId = updateUser.bind(null, userId);
return (
<form action={updateUserWithId}>
<input type="text" name="name" />
<button type="submit">Update User Name</button>
</form>
);
}
서버액션은 userId
인자를 추가적으로 form data에서 받을 수 있을 것이다.
"use server";
export async function updateUser(userId, formData) {
// ...
}
form이 제출되고 pending state일 때 보여주기 위해서useFormStatus
훅을 사용할 수 있다.
useFormStatus
는 특정 <form>
상태를 리턴한다. 그래서 꼭 <form>
요소의 자식으로써 정의되어야한다.useFormStatus
는 리액트훅이므로 클라이언트 컴포넌트에서 사용되어야한다."use client";
import { useFormStatus } from "react-dom";
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" aria-disabled={pending}>
Add
</button>
);
}
<SubmitButton/>
는 모든 form에 네스팅이 가능하다:
import { SubmitButton } from "@/app/submit-button";
import { createItem } from "@/app/actions";
// Server Component
export default async function Home() {
return (
<form action={createItem}>
<input type="text" name="field-name" />
<SubmitButton />
</form>
);
}
required
와 type="email"
같은 기본적인 클라이언트 사이드 form 유효성인 HTML 유효성을 사용하는 것을 권장한다.
좀 더 고급기능의 서버사이드 유효성을 위해서는 zod 같은 라이브러리를 사용하여 데이터를 mutating하기 전에 form 필드의 유효성을 검증할 수 있다.
// actions.ts
"use server";
import { z } from "zod";
const schema = z.object({
email: z.string({
invalid_type_error: "Invalid Email",
}),
});
export default async function createUser(formData: FormData) {
const validatedFields = schema.safeParse({
email: formData.get("email"),
});
// Return early if the form data is invalid
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
};
}
// Mutate data
}
서버에서 필드들이 유효성이 검증되면, 액션에서 직렬화된 객체를 리턴할 수 있고 useFormState
훅을 사용해서 사용자에게 메세지를 보여줄 수 있다.
useFormState
에 액션을 바이패싱하면 액션의 함수 시그니처가 변경되어 첫 번째 인수로 새로운 prevState 또는 initialState 파라미터를 받는다.useFormState
는 리액트훅이니까~~~ 클라컴포넌트에만!"use server";
export async function createUser(prevState: any, formData: FormData) {
// ...
return {
message: "Please enter a valid email",
};
}
그리고 useFormState
훅에 액션을 전달하여 리턴받은 state
를 사용하여 에러메세지를 보여줄 수 있다.
"use client";
import { useFormState } from "react-dom";
import { createUser } from "@/app/actions";
const initialState = {
message: "",
};
export function Signup() {
const [state, formAction] = useFormState(createUser, initialState);
return (
<form action={formAction}>
<label htmlFor="email">Email</label>
<input type="text" id="email" name="email" required />
{/* ... */}
<p aria-live="polite" className="sr-only">
{state?.message}
</p>
<button>Sign up</button>
</form>
);
}
데이터를 mutating하기전에 항상 사용자에게 해당 작업을 수행할 권한이 있는지 확인해야한다. 이는 인증 및 권한 부여 참조
useOptimistic
훅을 사용하여 응답을 기다리지 않고 서버액션이 완료되기 전에 UI를 Optimistic하게 업데이트할 수 있다:
"use client";
import { useOptimistic } from "react";
import { send } from "./actions";
type Message = {
message: string;
};
export function Thread({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[]>(
messages,
(state: Message[], newMessage: string) => [
...state,
{ message: newMessage },
]
);
return (
<div>
{optimisticMessages.map((m, k) => (
<div key={k}>{m.message}</div>
))}
<form
action={async (formData: FormData) => {
const message = formData.get("message");
addOptimisticMessage(message);
await send(message);
}}
>
<input type="text" name="message" />
<button type="submit">Send</button>
</form>
</div>
);
}
중첩된 <form>
, <button>
등등의 요소에 서버액션을 호출할 수 있다. 이 요소들은 formAction
을 prop 또는 이벤트핸들러에서 받는다.
이는 여러 서버액션들을 form에서 호출하고 싶을때와 같은 특정 케이스들에 유용하다. 예를 들면 <button>
요소를 만들어 draft post를 저장하고 추가적으로 퍼블리싱까지 할 때와 같은 경우이다. React <form> docs 참고
requestSubmit()
메소드를 트리거하여 폼양식 제출이 가능하다. 예를 들면, 사용자가 커맨드+엔터키를 눌렀을때, onKeyDown
이벤트를 통해 폼양식을 제출하려면:
"use client";
export function Entry() {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (
(e.ctrlKey || e.metaKey) &&
(e.key === "Enter" || e.key === "NumpadEnter")
) {
e.preventDefault();
e.currentTarget.form?.requestSubmit();
}
};
return (
<div>
<textarea name="entry" rows={20} required onKeyDown={handleKeyDown} />
</div>
);
}
이런 방식으로 하는 경우, 가장 가까이에 있는 부모의 <form>
이 트리거되어 서버액션이 호출된다.
<form>
요소를 사용해서 서버액션을 사용하는 것이 일반적이지만, 이벤트핸들러와 useEffect
와 같은 코드를 사용하여 호출할 수도 있다.
onClick 같은 이벤트핸들러를 통해 서버액션을 invoke할 수도 있다. 예를 들면:
"use client";
import { incrementLike } from "./actions";
import { useState } from "react";
export default function LikeButton({ initialLikes }: { initialLikes: number }) {
const [likes, setLikes] = useState(initialLikes);
return (
<>
<p>Total Likes: {likes}</p>
<button
onClick={async () => {
const updatedLikes = await incrementLike();
setLikes(updatedLikes);
}}
>
Like
</button>
</>
);
}
사용자경험을 더 좋게 하기 위해, useOptimistic
과 useTransition
과 같은 서버액션에서 응답을 받아오기 전 UI나 pending state 상태일 때를 고려하여 React API를 사용하는 것을 권장한다.
이벤트핸들러를 form elements에도 등록할 수 있다. 예를 들면 onChange
를 사용하여 form 필드를 저장할 때:
"use client";
import { publishPost, saveDraft } from "./actions";
export default function EditPost() {
return (
<form action={publishPost}>
<textarea
name="content"
onChange={async (e) => {
await saveDraft(e.target.value);
}}
/>
<button type="submit">Publish</button>
</form>
);
}
여러 이벤트가 연속적으로 실행될 수 있는 경우에는 불필요한 서버액션 호출을 방지하기 위해서 디바운싱을 권장한다.
useEffect
React useEffect 훅을 사용하여 컴포넌트가 마운트되었을 때나 의존성이 바뀌었을때 서버액션을 호출할 수도 있다. 이것은 글로벌 이벤트에 의존하거나, 자동으로 트리거되어야하는 mutation에 유용하다. 그 예로 앱의 숏컷으로 onKeyDown이나 무한스크롤링을 위한 옵저버 훅, 또는 컴포넌트가 마운트되어 조회 수를 업데이트할 때 등을 들 수 있다.
"use client";
import { incrementViews } from "./actions";
import { useState, useEffect } from "react";
export default function ViewCount({ initialViews }: { initialViews: number }) {
const [views, setViews] = useState(initialViews);
useEffect(() => {
const updateViews = async () => {
const updatedViews = await incrementViews();
setViews(updatedViews);
};
updateViews();
}, []);
return <p>Total Views: {views}</p>;
}
에러가 발생했을때, 가장 가까이에 있는 error.js
를 가져오거나, 클라이언트에서는 <Suspense>
바운더리를 보여준다. try/catch
를 사용하여 에러를 반환하는 것으로 UI를 핸들링하는 것을 권장한다.
예를 들면 서버액션에서 에러가 발생했을때 메세지를 반환하여 에러를 처리할 수 있다.
export async function createTodo(prevState: any, formData: FormData) {
try {
// Mutate data
} catch (e) {
throw new Error("Failed to create task");
}
}
오류를 던지는 것 외에 useFormState에서 처리할 객체를 반환할 수도 있다.
revalidatePath
API를 사용하여 서버액션 안의 nextjs cache를 제거할 수 있다.
또는 revalidateTag
를 사용하여 캐시태그가 있는 특정 데이터 가져오는 것을 무효화한다.
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function createPost() {
try {
// ...
} catch (error) {
// ...
}
revalidatePath("/posts");
// or
revalidateTag("posts");
}
서버액션이 완료된 이후에 다른 페이지로 사용자를 리다이렉트 시키고 싶을 때 redirect
API를 사용할 수 있다. redirect
는 try/catch 블록 밖에서 호출되어야한다.
"use server";
import { redirect } from "next/navigation";
import { revalidateTag } from "next/cache";
export async function createPost(id: string) {
try {
// ...
} catch (error) {
// ...
}
revalidateTag("posts"); // Update cached posts
redirect(`/post/${id}`); // Navigate to the new post page
}
cookies
API를 사용하여 서버액션 내부에서 쿠키를 get, set, delete할 수 있다.
"use server";
import { cookies } from "next/headers";
export async function exampleAction() {
// Get cookie
const value = cookies().get("name")?.value;
// Set cookie
cookies().set("name", "Delba");
// Delete cookie
cookies().delete("name");
}
서버액션을 public-facing API 엔드포인트로 취급하고 사용자가 액션을 수행할 권한이 있는지 확인해야할 것이다.
"use server";
import { auth } from "./lib";
export function addItem() {
const { user } = auth();
if (!user) {
throw new Error("You must be signed in to perform this action");
}
// ...
}
컴포넌트 내부에 서버액션을 정의하면 외부 함수의 스코프에 접근할 수 있는 클로저가 생성된다. 예를 들어, publish 액션은 publishVersion이라는 변수에 접근할 수 있다.
export default function Page() {
const publishVersion = await getLatestVersion();
async function publish(formData: FormData) {
"use server";
if (publishVersion !== await getLatestVersion()) {
throw new Error('The version has changed since pressing publish');
}
...
}
return <button action={publish}>Publish</button>;
}
클로저는 나중에 액션이 호출될 때 사용할 수 있도록 렌더링 시점의 publishVersion 같은 데이터의 스냅샷을 캡처해야할 때 유용하다.
하지만 이렇게 되려면 캡쳐된 변수들이 액션이 호출되었을 때 클라이언트와 서버에 전송되어야한다. 민감한 데이터가 클라이언트에 노출되는 것을 피하기위해서 nextjs는 자동으로 closed-over(닫힌) 변수들로 암호화한다. nextjs 애플리케이션이 빌드될 때마다 각 액션에 매번 새로운 private key가 생성된다. 이것은 액션들이 특정 빌드에 대해서만 호출될 수 있도록 해준다는 것이다.
민감한 값들이 클라이언트에 노출되는 것을 방지하기 위해 암호화에만 의존하는 것은 권장하지 않는다. 대신 React taint API를 사용하여 특정 데이터가 클라이언트로 전송되는 것을 사전에 방지하여야한다.
여러 서버에서 nextjs 애플리케이션을 셀프 호스팅하는 경우, 각 서버 인스턴스가 서로 다른 암호화 키를 사용하게 되어 잠재적으로 이 부분이 일관적이지 않을 수 있다.
이걸 마이그레이팅하려면, process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY
환경변수를 사용하여 암호화키를 덮어씌울 수 있다. 이걸 명시함으로써 암호화 키가 빌드 간에 영구적으로 유지되고, 모든 서버 인스턴스가 동일한 키를 사용하는 것을 보장하게된다.
서버액션이 form 요소에서 호출된다면, CSRF 공격에 노출될 수 있다.
백그라운드에서 서버액션은 POST 메소드를 사용하고, 해당 메소드만이 이 액션들을 호출할 수 있게끔한다. 이것은 대부분의 모든 브라우저에서 CSRF 취약점들을 막을 수 있고, 특히 SameSite cookies가 기본값인 경우 더욱 그렇다.
추가적으로 nextjs의 서버액션은 Origin header와 Host header를 비교한다. 만약 이게 같지 않으면, request가 abort된다. 다시 말하자면, 서버액션은 같은 호스트에서만 호출할 수 있다.
리버스 프록시 또는 멀티레이어드 서버 아키텍처를 사용하는 거대한 애플리케이션에서는(프로덕션 도메인과 서버 API가 다를때) serverActions.allowedOrigins
옵션을 설정하여 안전한 origin들을 명시할 것을 권장한다. 이 옵션은 string의 배열을 받는다.
// next.config.js
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ["my-proxy.com", "*.my-proxy.com"],
},
},
};