Action - Dispatcher - Model - View
Elm 아키텍처
module Main exposing (...)
import Browser
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
-- MAIN
main =
Browser.sandbox {init = init, update = update, view = view}
-- MODEL
type alias Model = Int
init : Model
init =
0
-- UPDATE
type Msg
= Increment
| Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [onClick Decrement] [text "-"]]
, div[][text(String.fromInt model)]
, button [onClick Increment][text "+"]
<div>
<button>-<button>
<div>2</div>
<button>+</button>
</div>
리덕스는 하나의 상태 객체를 스토어에 저장해 두고, 이 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행한다. -> reducer 함수
reducer 함수 실행으로 웹 애플리케이션 상태에 대한 완전히 새로운 복사본을 반환한 뒤, 애플리케이션에 이 새롭게 만들어진 상태를 전파한다.
Context로 상태를 주입하고, 주입된 상태는 props로 값을 넘겨받지 않아도 사용할 수 있다.
이후 Recoil, Zustand, Jotai, Valtio 등 많은 라이브러리가 등장했다.
export type State = { counter: number }
// 상태를 아예 컴포넌트 밖에 선언
let state : State = {
counter: 0,
}
// geter
export function get(): State {
return state
}
// useState와 동일하게 구현하기 위해 게으른 초기화 함수나 값을 받을 수 있게 함
type initializer<T> = T extends any ? T | ((prev: T) => T) : never
// setter
export function set<T>(nextState: Initializer<T>){
state = typeof nextState === 'function' ? nextState(state) : nextState
}
// Counter
function Counter(){
const state = get()
function handleClick(){
set((prev : State) => ({ counter: prev.counter + 1 }))
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
)
}
function Counter1(){
const [count, setCount] = useState(state)
function handleClick(){
set((prev: State) => {
const newState = { counter: prev.counter + 1 }
setCount(newState)
return newState
})
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
)
}
function Counter2(){
const [count, setCount] = useState(state)
function handleClick(){
set((prev: State) => {
const newState = { counter: prev.counter + 1 }
setCount(newState)
return newState
})
}
return (
<>
<h3>{state.counter}</h3>
<button onClick={handleClick}>+</button>
</>
)
}
type Initializer<T> = T extends any ? T | ((prev: T) => T) : never
type Store<State> = {
get: () => State // 항상 새롭게 값을 가져오기 위해 시도한다.
set: (action : Initializer<State>) => State
subscribe: (callback: () => void) => () => void // store의 변경을 감지하고 싶은 컴포넌트들이 callback을 등록. store는 값이 변경될 때마다 모든 callback을 실행한다.
}
export const createStore = <State extends unknown>(
initialState: Initializer<State>,
): Store<State> => {
let state = typeof initialState !== 'function' ? initialState : initialState()
// callbacks는 자료형에 관계없이 유일한 값을 저장할 수 있는 Set 사용
const callbacks = new Set<() => void>()
const get = () => state
const set = (nextState: State | ((prev: State) => State)) => {
state =
typeof nextState === 'function'
? (nextState as (prev: State) => State)(state)
: nextState
callbacks.forEach((callback) => callback())
return state
}
const subscribe = (callback: () => void) => {
callbacks.add(callback)
// 클린업 실행 시 이를 삭제해서 반복적으로 추가되는 것을 막는다.
return () => {
callbacks.delete(callback)
}
}
return { get, set, subscribe }
}
// store의 값을 참조하고, 이 값에 변화에 따라 컴포넌트 렌더링을 유도할 사용자 정의 훅
export const useStore = <State extends unknown>(store: Store<State>) => {
const [state, setState] = useStaet<State>(() => store.get()) // 렌더링 유도
useEffect(() => {
const unsubscribe = store.subscribe(() => {
setState(store.get())
})
return unsubscribe
},[store]) // store 값이 변경될 때마다 state의 값이 변경되는 것을 보장받을 수 있다.
return [state, store.get] as const
}
export const useStoreSelector = <State extends unknown, Value extends unknown>(
store: Store<State>,
selector: (state: State) => Value // store 상태에서 어떤 값을 가져올 지 정의하는 함수
) => {
const [state, setState] = useState(() => selector(store.get()))
useEffect(() => {
const unsubscribe = store.subscribe(() => {
const value = selector(store.get())
})
return unsubscribe
}, [store, selector])
return state
}
// Context를 생성하면 자동으로 스토어도 함께 생성한다
export const CounterStoreContext = createContext<Store<CounterStore>>(
createStore<CounterStore>({ count: 0, text: 'hello' }),
)
export const CounterStoreProvider = ({
initialState,
children
} : PropsWithChildren<{
initialState: CounterStore
}>) => {
const storeRef = useRef<Store<CounterStore>>() // Provider로 넘기는 props가 불필요하게 변경돼서 리렌더링되는 것을 막기 위해
// 스토어를 생성한 적이 없다면 최초 한 번 생성한다
if(!storeRef.current){
storeRef.current = createStore(initialState)
}
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export const useCounterContextSelector = <State extends unknown>(
selector: (state: CounterState) => State,
) => {
const store = useContext(CounterStoreContext) // Context.Provider에서 제공된 스토어를 찾게 만든다.
const subscription = useSubscription(
useMemo(
() => ({
getCurrentValue: () => selector(store.get()),
subscribe: store.subscribe
})
,[store, selector])
)
return [subscription, store.set] as const
}
// 사용
const ContextCounter = () => {
const id = useId()
const [counter, setCounter] = useCounterContextSelector(
useCallback((state: CounterStore) => state.count, []),
)
function handleClick(){
setStore((prev) => ({...prev, count: prev.count + 1}))
}
useEffect(() => {
console.log(`${id} Counter Rendered`)
})
return (
<div>
{counter} <button onClick={handleClick}>+</button>
</div>
)
}
서로 다른 context를 바라보게 할 수 있다.
export default function App(){
return (
<>
<ContextCounter/> // 0
<CounterStoreProvider initialState={{ count: 10, text: 'hello' }}>
<ContextProvider/> // 10
<CounterStoreProvider initialState={{ count: 20, text: 'welcome' }}>
<ContextProvider/> // 20
</CounterStoreProvider>
</CounterStoreProvider>
</>
)
}