Kotlin/JS 상태관리 4 – FINAL

리액트 기본 useReducer를 쓰는건 아무리 생각해도 너무 개같아서 결국 Zustand를 래핑해서 쓰기로 했다.

기존에는 Zustand를 통째로 들어서 Kotlin/JS 로 바꾸려는 원대한 꿈을 가지고 있었으나 사실 그럴 필요가 없는게 겉으로 공개된 API에 대한 인터페이스만 만들면 되는거라 그렇게 다시 접근…

Mutate 가 결국에는 종착점이 StoreApi이기 때문에 이 끝점만 알면 되는거고 중간에 뭐가 있든 신경을 안쓰면 된다.

그래서 vanilla.ts에서 뽑아내면 다음과 같이 나온다.

external interface SetStateByState<T> {
    @JsName("call")
    operator fun invoke(self: dynamic = definedExternally, state: T)
}

external interface SetStateByTransform<T> {
    @JsName("call")
    operator fun invoke(self: dynamic = definedExternally, transform: (T) -> T)
}

external interface SetState<T> : SetStateByState<T>, SetStateByTransform<T>

@JsModule("zustand/vanilla")
@JsNonModule
@JsName("createStore")
external val createStoreModule: dynamic

@JsModule("zustand/vanilla")
@JsNonModule
external interface StoreApi<T> : ReadonlyStoreApi<T> {
    override var getState: () -> T
    var setState: dynamic
}

typealias StateCreator<T> = (setState: SetState<T>, getState: () -> T, store: StoreApi<T>) -> T

fun <T> createStore(initializer: StateCreator<T>): StoreApi<T> {
    return createStoreModule.createStore(initializer).unsafeCast<StoreApi<T>>()
}

fun <T> createStore(): ((initializer: StateCreator<T>) -> StoreApi<T>) {
    return { initializer ->
        createStore(initializer)
    }
}

자바스크립트가 덕타이핑이다보니 StoreApi와 ReadonlyStoreApi가 StoreApi에서 setState만 제외하고 가지고 있는걸로 선언되어 있는데 코틀린은 그게 안되다보니 그냥 ReadonlyStoreApi를 상속하는 걸로 되어 있다. getState는 Readonly에서는 val로 되어 있는데 var로 오버라이딩 하는데 사실 이건 할 필요가 없다. 왜 남겨놨지

다음은 react.ts다.

package zustand

external interface ReadonlyStoreApi<T> {
    val getState: () -> T
    val getInitialState: () -> T
    val subscribe: (listener: (state: T, prevState: T) -> Unit) -> () -> Unit
}

@JsModule("zustand")
@JsNonModule
@JsName("useStore")
external val useStoreModule: dynamic

fun <T> useStore(store: ReadonlyStoreApi<T>): T {
    return useStoreModule.useStore(store).unsafeCast<T>()
}

fun <T, U> useStore(store: ReadonlyStoreApi<T>, selector: (T) -> U): U {
    return useStoreModule.useStore(store, selector).unsafeCast<U>()
}

간단한 것만 옮겨놓았다.

이런식으로 해놓으면 웬만한건 다 된다.

미들웨어

사실 useState 어쩌구에서 미들웨어가 없으니 뭔가 매우 불편하다. 위에서 Mutator가 무시가능하다는 것을 알았으므로 미들웨어도 옮길 수 있다.

Zustand의 미들웨어는 스토어를 만들기 위해 넘기는 StateCreator를 인자로 받아서 또 다른 StateCreator를 넘기는 것이라고 보면 된다.

다만 이미 정의된 몇개의 미들웨어는 따로 옵션이 있어서 이것만 대충 코드 보고 만들면 된다.

package zustand.middlewares

@JsModule("zustand/middleware")
@JsNonModule
external val middlewaresModule: dynamic

기정의된 미들웨어를 가져오기 위한 코드다. 가져올건 devtools와 persist이다.

import zustand.StateCreator

external interface DevtoolsOptions {
    var name: String?
    var enabled: Boolean?
    var anonymousActionType: String?
    var store: String?
}

fun <T> devtools(creator: StateCreator<T>): StateCreator<T> {
    return devtools(creator, null)
}

fun <T> devtools(creator: StateCreator<T>, options: DevtoolsOptions?): StateCreator<T> {
    return if (options == null) {
         middlewaresModule.devtools(creator)
    } else {
        middlewaresModule.devtools(creator, options)
    }.unsafeCast<StateCreator<T>>()
}
import js.promise.PromiseLike
import zustand.StateCreator

external interface StateStorage {
    var getItem: (name: String) -> PromiseLike<String?>
    var setItem: (name: String, value: String) -> PromiseLike<Unit>
    var removeItem: (name: String) -> PromiseLike<Unit>
}

external interface StorageValue<S> {
    var state: S
    var version: Number?
}

external interface PersistStorage<S> {
    var getItem: (name: String) -> PromiseLike<StorageValue<S>?>
    var setItem: (name: String, value: StorageValue<S>) -> PromiseLike<Unit>
    var removeItem: (name: String) -> PromiseLike<Unit>
}

external interface PersistOptions<S, PersistedState> {
    var name: String
    var storage: PersistStorage<PersistedState>?
    var partialize: ((S) -> PersistedState)?
    @JsName("onRehydrateStorage")
    var onRehydrateStorage: ((state: S) -> Unit)?
    @JsName("onRehydrateStorage")
    var onRehydrateStorageWithCallback: ((state: S) -> ((state: S?, error: Any?) -> Unit))?
    var version: Number?
    var migrate: ((persistedState: dynamic, version: Number) -> PromiseLike<PersistedState>)?
    var merge: ((persistedState: dynamic, currentState: S) -> S)?
    var skipHydration: Boolean?
}

fun <T, S> persist(creator: StateCreator<T>, options: PersistOptions<T, S>): StateCreator<T> {
    return middlewaresModule.persist(creator, options).unsafeCast<StateCreator<T>>()
}

fun <T> persist(creator: StateCreator<T>, options: PersistOptions<T, T>): StateCreator<T> {
    return middlewaresModule.persist(creator, options).unsafeCast<StateCreator<T>>()
}

대충 이런 식으로 옮기면 된다.

커스텀 미들웨어

그래서 redux logger같은 로거를 만들거다. 여기서 좀 오래걸렸다.

  1. 일단 첫째로, zustand 미들웨어를 만들어본 적이 없어서 어떤 식으로 만들어야할지 몰랐다. 대충 이렇게 하면 될거같은데 실행해보니 안됨… 여기서 좀 멍하게 있었다.
  2. Kotlin/JS 는 ES5 이다. zustand 미들웨어 구현들을 보면 파라미터를 spread해서 넘기는 부분이 있는데 이게 ES5에서는 지원을 하지 않는다. 이걸 몰라서 이걸 어떻게 해결해야하나 Jetbrains AI Assistant에게 물어보니 Function.prototype.apply를 쓰면 된다고 한다… apply는 call과 같이 함수를 실행시키는데, 두번째 파라미터로 전체 파라미터의 어레이를 받는다. 여기에 넘기는 값은 arguments 를 넘기면 될 듯 하다.
import zustand.SetState
import zustand.StateCreator

private fun <T> groupedLog(previousState: T, nextState: T, action: dynamic) {
    js("console.group('%caction %c%s', 'color:red;font-weight:bold;', 'color:inherit;font-weight:inherit;', action.name || 'Partial Update')")
    console.log("%cprev state %o", "color:grey;font-weight:bold;", previousState)
    console.log("%caction %o", "color:blue;font-weight:bold;", action)
    console.log("%cnext state %o", "color:green;font-weight:bold;", nextState)
    js("console.groupEnd()")
}

private fun <T> spreadLog(previousState: T, nextState: T, action: dynamic) {
    console.log("%caction %c%s", "color:red;font-weight:bold;", "color:inherit;font-weight:inherit;", action.name ?: "Partial Update")
    console.log("%cprev state %o", "color:grey;font-weight:bold;", previousState)
    console.log("%caction %o", "color:blue;font-weight:bold;", action)
    console.log("%cnext state %o", "color:green;font-weight:bold;", nextState)
}

fun <T> zustandLogger(stateCreator: StateCreator<T>): StateCreator<T> {
    // Do not shadow `set`. It is used within `js` function call.
    return { set, get, api ->
        val newSet: SetState<T> = ({ action: dynamic ->
            val previousState = get()
            // kotlin/js uses ES5. `set(...arguments)` does not work.
            js("set.apply(null, arguments)")
            val nextState = get()
            if (jsTypeOf(js("console.group")) == "undefined") {
                spreadLog(previousState, nextState, action)
            } else {
                groupedLog(previousState, nextState, action)
            }
        }).unsafeCast<SetState<T>>()

        stateCreator(newSet, get, api)
    }
}

요런식으로 하면 잘 작동한다.

그래서 다음과 같이 쓰면 된다.

val AppStateStore = createStore(
    devtools(persist(zustandLogger(createAppState), jso {
        name = "ZustandAppStateTests"
    }))
)

오늘의 코드는 여기에 있다.

https://github.com/Aosamesan/zustand-kotlin-wrapper

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다