jsreactorkit v1.0.1
JS - ReactorKit
Inspired by ReactorKit
React로 웹 개발시에 컴포넌트의 State를 변경하는 로직을 구분하고, 테스트를 보다 직관적으로 진행하기 위해 ReactorKit을 본따 만들었습니다.
installation
npm i jsreactorkitConcept
View call(Action) -> Reactor[Action-> Mutation -> State] -> View update(State)
- Action : 뷰의 행동입니다. 순수하게 뷰의 행동만을 추상화 합니다. Mutate에게 변할 내용에 대한 힌트를 줍니다.
- State: 뷰의 상태 입니다. 뷰의 결과 상태를 추상화합니다.
- Mutation : 액션의 행동으로 변하게 되는 뷰의 상태에 대한 정의를 하는 부분입니다. (side-effect 는 여기서 일어납니다). State에게 변할 내용의 힌트를 줍니다.
Action, Mutation , State에 대한 자세한 내용은 ReactorKit 에서 확인할 수 있습니다.
- ReactorKit은 리액터 내부의 흐름을
RxJs로 연결합니다. 즉, Action, Mutation, State는 각각Rx.Observable로 연결되어있습니다. - View에서 Action으로 신호를 emit 하면, Action Observable은 그 신호를 받고
mutate()를 통해 Action->Mutation을 진행합니다. 완료시에, Mutation Observable로 전달합니다. Mutation Observable은reduce()를 통해 Mutation 단계의 결과값을 새로운 state로 만들고 State Observable을 부릅니다. State Observable은 전달받은 새로운 state를 currentState에 업데이트 합니다. RxJs로 구성되어있기때문에RxJs의 강력한 오퍼레이터들을 사용하여 개발 및 테스팅을 할 수 있습니다.
메소드
생성자
| parameter | required | default |
|---|---|---|
| initialState | true | none |
| isStubEnabled | false | false |
| isGlobal | false | false |
- initialState : state의 처음 상태입니다.
- isStubEnabled : 테스팅용 Stub입니다. View와의 바인딩을 체크하기 위한 용도입니다.
- isGlobal: 해당 리액터가 글로벌로 사용될경우, unsubscribe()를 방지하기위해 사용됩니다.
Mutate & Reduce
- mutate()는 action으로부터 state를 변경시킬 수 있는 로직을 작성하는 부분입니다.
mutate(action: ForumAction): Observable<ForumMutation> {
switch(action.type) {
case "CLICKTOPIC":
return concat(
//topic change
of({type:"TOPICCHANGE", topic: action.newTopic}),
//is Loading
of({type:"SETLOADING", isLoading: true}),
//WebRequest
this.fetchList(action.newTopic).pipe(
takeUntil(this.action.pipe(filter(value => value === action))),
map( res => {
return {type:"FETCHLIST", list: res, page: 1 }
})
...
...
}- reduce()는 mutation의 결과로부터 state를 업데이트 하는 부분입니다.
reduce(state: ForumState, mutation: ForumMutation): ForumState {
let newState = state;
switch(mutation.type) {
case "TOPICCHANGE":
newState.topic = mutation.topic
return newState;
}
...
}Transform
transform 메소드는 각 스테이지의 Observable을 변경할 수 있습니다. Observable을 확장하거나, 특정 operator를 일괄적으로 적용하고 싶을때 사용할 수 있습니다.
transformState(state: Observable<State>): Observable<State> {
return merge(state, otherReactor.state)
}
// 이제 이 reactor는 state는 otherReactor의 state또한 구독합니다.transformState(state: Observable<State>): Observable<State> {
return state.pipe( tap( _ => console.log("state update!"))
}
// 이제 이 reactor는 state는 업데이트 될때마다 console에 로그를 찍습니다.Scheduler
async 스케줄러를 사용하면, 원하는 결과값을 도출 할 수 없습니다. 기본 스케줄러는 queueScheduler 입니다. 자세한 내용은 rxjs scheulder를 참조하세요.
disposeAll
reactor.disposeAll();리액터 안에 사용되는 Observable들의 Subscription 해지합니다.
React-바인딩
리액트의 컴포넌트와 바인딩 할 수 있는 HOC와 메소드도 지원합니다.
ReactiveView (HOC)
export default ReactiveView(Component);사용하기위해서는 Component가 ReactorView 라는 인터페이스를 구현해야합니다.
ReactorView
ReactorView<P extends Reactor<any,any,any>> {
bind(reactor:P):DisposeBag;
reactor?:P;
}ReactiveView는 Component의 ComponentDidMount()이후에 bind()라는 함수를 추가적으로 불러 바인딩을 완성시킵니다.
Bind
bind 메소드는 Component(View)와 Reactor를 연결시켜주는 부분입니다.
예제
componentDidMount(){
this.reactor = new AnyReactor({...})
}
bind(reactor: TableReactor): DisposeBag {
let disposeBag = new DisposeBag();
disposeBag.disposeOf = reactor.state.pipe(
map( res => res.data ),
deepDistinctUntilChanged(),
).subscribe( data => {
this.setState({data})
})
return disposeBag;
}Global
React의 Context API와 HOC을 이용해 글로벌 스토어도 지원합니다.
1. Store 등록
글로벌 스토어로 사용할것을 앱의 최상단 루트에서 register함수를 이용해 등록합니다.
const value = register([new ModalReactor({isOpened: false},false,true)])2. Provider
앱의 최상단에서 GlobalReactor.Provider로 감싸줍니다.
App.tsx //최상단
const value = register([new ModalReactor({isOpened: false},false,true)])
render(){
return (
<GlobalReactor.Provider value={value}>
....
.....
</GlobalReactor.Provider>
)
}3. State 바꾸기 / 구독하기.
3-1. Export using Wrapper.
마찬가지로 전용 뷰 Wrapper인 Global이라는 함수를 지원합니다.
Global에서는 Reactor의 이름으로 글로벌 리액터중에서 원하는 리액터를 선택해야합니다.
export default Global(SomeView, ModalReactor.name)3-2. "SomeView" 구현하기.
위의 SomeView예시처럼 Global(SomeView, ...)에 내가 작업하던 뷰를 담으려면, 몇가지 규칙이 있습니다.
기존 관리하던 로컬
State와 차별점을 두기위해서 글로벌 상태는Props로 받을 수 있습니다.지금 작성하고 있는 뷰에서 받고싶은
State와Reactor의 타입을 인터페이스GlobalReactorProps<T,K>를 통해 명시하고, Props로 받는다고 선언합니다.
예제 - state받기
class TestViewGlobalGetState extends React.Component<GlobalReactorProps<ModalReactor,ModalState>>{
render(){
return(
<Button>
{this.props.globalState.isOpened? "OPENED" : "UNOPENED"}
</Button>
)
}
}예제 - state바꾸기
class TestViewGlobalChangeState extends React.Component<GlobalReactorProps<ModalReactor,ModalState>>{
render(){
return(
<Button onClick={()=>{this.props.globalRactor.action.next({type:"MODALTOGGLE"})}}>
바꾸는버튼
</Button>
)
}
}예제 - export
export const GLOBALTEST = Global(TestViewGlobalGetState, ModalReactor.name)
export const GLOBALTEST2 = Global(TestViewGlobalChangeState, ModalReactor.name)ReactorHook (beta)
Functional Component 에서 리액터를 사용할 수 있도록 나온 FC전용 리액터 입니다. Reactor와 기본방식은 같으나 상태관리에 사용할 수 있는 custom hook을 추가하였습니다.
- 뷰와 별도의 바인딩 과정 없이, state의 변화가 감지되면 뷰가 업데이트됩니다.
const initalState = {a:123}
function MyView(){
const [reactor, currentState] = MyReactor.use(initalState);
return (
<>
<button onClick={()=>{reactor.action.next({})}}>button</button>
<div> {currentState.a}</div>
</>
)
}class MyReactor extends ReactorHook<Action,State..> {
mutate(){
.....
}
reduce() {
....
}
}테스팅
(자동) 테스팅 대상.
리액터의 검증.
- 리액터가 원하는 방식대로 Action->Mutate->State 를 따르는지 체크합니다.
- 로직 테스트이기 때문에 간단히 테스트할 수 있습니다.
뷰와 리액터의 bind() 검증.
- 뷰와 리액터가 연결되어 있는지 체크합니다.
- 테스트가 까다롭지만, 컴포넌트와 리액터가 서로 바인딩만 되어있는지 검증합니다.
1. 리액터 검증
예제 1) action -> state 변경 테스트.
it('click write -> mode change test ', done => {
reactor = new ForumReactor(initialState);
// 액션을 보냅니다.
reactor.action.next({type:"CLICKWRITE"})
// 액션에 따라 state가 변하는지 체크합니다.
expect(reactor.currentState.mode).toBe("edit")
done();
})예제 2) 다양한 액션이 일어나는 경우에 대한 테스트.
it('5. side effect : click topic -> topic change -> loading -> (success) -> loading -> isError false test', done => {
//moxios 목업 구성.
moxios.wait(() => {
const request = moxios.requests.mostRecent()
request.respondWith({ status: 200, response: listResultMockup })
})
reactor = new ForumReactor(initialState);
let state_change = 0;
//리액터의 변경을 여기서 구독합니다.
//concat을 통해 여기서 전달받습니다.
from(reactor.state).subscribe(
state => {
if(state_change === 1) {
expect(state.topic).toBe("tips");
} else if (state_change === 2) {
expect(state.isLoading).toBeTruthy();
} else if (state_change === 3) {
expect(state.list.length).toBe(2);
} else if (state_change === 4) {
expect(state.isLoading).toBeFalsy();
expect(state.isError).toBeFalsy();
done();
} else {
done.fail();
}
state_change++;
}
)
reactor.action.next({type:"CLICKTOPIC", newTopic: "tips"})
})View - Reactor 바인딩 검증.
1. 뷰코드 예시.
class SomeView extends React.Component<{}, ForumState> implements ReactorView<ForumReactor> {
button?: HTMLElementSubject;
reactor?: ForumReactor | undefined
constructor(props:{}){
super(props);
this.state = {
isError: false,
isLoading: false,
page: 1,
mode:"list",
topic:"clan",
post: undefined,
list:[],
}
}
componentDidMount(){
this.reactor = new ForumReactor(this.state);
}
bind(reactor: ForumReactor): DisposeBag {
let disposeBag = new DisposeBag();
reactor?.state.pipe(
map(res => res.mode),
deepDistinctUntilChanged()
).subscribe( mode => this.setState({mode}))
return disposeBag;
}
render(){
return (
<button onClick={()=>{this.reactor.next({type:"CLICKBACK"}}></button>
)
}
}2. 테스트 코드
it('9. View Binding Check', done => {
reactor = new ForumReactor(initialState, true);
// enzyme을 통해 마운트시키기
const wrapper = shallow(<R6Table></R6Table>);
// 1. 리액터가 이미 존재하는지 체크.
expect((wrapper.instance() as any).reactor).not.toBe(undefined);
// 2. 테스트에서 사용할 리액터를 주입.
(wrapper.instance() as any).reactor = reactor;
// 3. bind()를 수동으로 다시 불러 리액터 업데이트 시키기.
(wrapper.instance() as any).bind(reactor);
// 4. enzyme을 통해 button을 시뮬레이트한다.
wrapper.find('button').at(0).simulate('click')
// 5. stub은 모든 액션 기록을 저장한다. 액션을 비교한다.
expect(reactor.stub.lastAction.type).toBe("CLICKBACK");
// 6. stub은 또한 state에서 액션을 내보낼 수 있다.
reactor.stub.state.next({...initialState, mode : "edit"});
// 7. 액션을 내보낸 뒤, state가 제대로 변경되었는지 체크한다.
expect((wrapper.state() as ForumState).mode).toBe("edit");
done();
})그 외 예제.
Action & Mutation & State정의
interface State {
value: number
}
export const INCREASE = 'INCREASE'
export const DECREASE = 'DECREASE'
interface INCREASEACTION {
type: typeof INCREASE
}
interface DECREASEACTION {
type: typeof DECREASE
}
export type ActionType = INCREASEACTION | DECREASEACTIONRedux의 유틸 라이브러리를 활용해 조금더 타입을 생성할 수 있습니다.
typesface-actions
To-do list
- initial Commit
- 비동기 대응.
- 비동기 처리 에러.
- 프로젝트 테스트 코드 추가 및 테스트.
- 테스트 기능.
- 문서작성.
- 뷰 .
- 훅기능 추가 (beta)
- 코드 테스트.
- 디버깅 기능 추가.
Dependency
- Rxjs