Skip to content

Commit

Permalink
next in middlewares now returns a promise that is resolved after the …
Browse files Browse the repository at this point in the history
…reducer has run
  • Loading branch information
Lenz Weber committed Apr 26, 2019
1 parent a1dda31 commit e995907
Show file tree
Hide file tree
Showing 3 changed files with 111 additions and 8 deletions.
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ const [state, dispatchAction] = useLocalSlice({
data: state.data.toUpperCase()
})
// more reducers ...
}
},
middlewares: [] // optional, takes an array of redux middlewares. see warnings below.
});
```

Expand All @@ -37,6 +38,38 @@ dispatchAction.toUpper();

use-local-slice provides one dispatchAction method per reducer, and (for typescript users) ensures that these dispatchers are only called with correct payload types.

## On Middlewares

Most Redux middlewares should work out of the box with use-local-slice.

_But there are exceptions:_

Due to the asynchronity of React's useReducer (the reducer is only executed on next render), the following construct will not work:

```javascript
const middleware = store => next => action => {
console.log("state before next() is", store.getState());
const retVal = next(action);
console.log("state after next() is", store.getState());
return retVal;
};
```

That code will log the state _before_ calling the reducer twice, because the reducer is executed asynchronously on next render.

As this behaviour is highly undependable in Redux anyways (if a middleware that comes after this middleware defers the call to `next(action)` to a later moment, you have the same behaviour there, too), most Redux middlewares will not depend on that behaviour to work, so in general this should break nothing.

But, _if_ you truly need this behaviour, the following should work (unless another middleware meddles with the call to `next(action)` too much):

```javascript
const middleware = store => next => async action => {
console.log("state before next() is", store.getState());
const retVal = await next(action);
console.log("state after next() is", store.getState());
return retVal;
};
```

## Edge case uses & good to know stuff

- reducers can directly reference other local component state & variables without the need for a `dependencies` array. This is normal `useReducer` behaviour. You can read up on this on the overreacted blog: [Why useReducer Is the Cheat Mode of Hooks](https://overreacted.io/a-complete-guide-to-useeffect/#why-usereducer-is-the-cheat-mode-of-hooks)
Expand Down
36 changes: 34 additions & 2 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ describe("immer integration in reducers", () => {
});

describe("middlewares", () => {
it("can access the state before next", () => {
it("can access the state before next(action)", () => {
let before: any;
const middleware: Middleware = store => next => action => {
before = store.getState();
Expand All @@ -419,7 +419,7 @@ describe("middlewares", () => {
expect(before).toEqual({ stringProp: "hello" });
});

it("can access the state after next", () => {
it("can NOT! access the state after next(action) synchronously", () => {
let after: any;
const middleware: Middleware = store => next => action => {
next(action);
Expand All @@ -440,6 +440,38 @@ describe("middlewares", () => {

act(() => result.current[1].concat("test"));

expect(after).not.toEqual(result.current[0]);
});

it("can access the state after next(action) by awaiting next asynchronously", async () => {
let after: any;
let onMiddlewareFinished: () => void;
let middlewareFinished = new Promise(
resolve => (onMiddlewareFinished = resolve)
);

const middleware: Middleware = store => next => async action => {
await next(action);
after = store.getState();
onMiddlewareFinished!();
};

const { result } = renderUseLocalSlice({
initialState: {
stringProp: "hello"
},
reducers: {
concat(state, action: { payload: string }) {
return { stringProp: state.stringProp + action.payload };
}
},
middlewares: [middleware]
});

act(() => void result.current[1].concat("test"));

await middlewareFinished;

expect(after).toEqual(result.current[0]);
});

Expand Down
48 changes: 43 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,39 @@ export interface UseLocalSliceOptions<
middlewares?: Middleware[];
}

const nullMiddleware: Middleware = () => next => action => next(action);
/**
* returns a promise that will be resolved on the next render
*/
function useNextRenderPromise() {
const postDispatch = useRef<{ promise: Promise<void>; resolve: () => void }>({
promise: Promise.resolve(),
resolve: () => 0
});
postDispatch.current.resolve();
postDispatch.current.promise = new Promise(
resolve => (postDispatch.current.resolve = resolve)
);

return postDispatch.current.promise;
}

/**
* Essentially useReducer, but applies middlewares around the `dispatch` call.
*/
function useCreateStore<R extends Reducer<any, any>>(
reducer: R,
initialState: ReducerState<R>,
middlewares: Middleware[]
) {
const [state, finalDispatch] = useReducer(reducer, initialState);
const nextRenderPromise = useNextRenderPromise();

const stateRef = useRef(state);
stateRef.current = state;

/**
* Wrap `dispatch` in the middlewares
*/
const dispatch: typeof finalDispatch = useMemo(() => {
let dispatch = (...args: any[]) => {
throw new Error(/*
Expand All @@ -82,8 +103,22 @@ function useCreateStore<R extends Reducer<any, any>>(
}
};
const chain = middlewares.map(middleware => middleware(middlewareAPI));
dispatch = compose<any>(...chain)(finalDispatch);
return dispatch;

/*
* The dispatch that will be passed to the middlewares.
* Returns a promise that will be resolved on the next render, after useReducer executed the reducer.
*/
dispatch = compose<any>(...chain)(
(action: any) => (finalDispatch(action), nextRenderPromise)
);
/*
* The dispatch we return to the outside should not return a promise to not encourage it's use outside of middlewares.
* But if that return value has been changed by a middelware, that changed return value will be forwarded.
*/
return (...args) => {
const retVal = dispatch(...args);
return retVal === nextRenderPromise ? undefined : retVal;
};
}, [middlewares]);

return [state, dispatch] as [typeof state, typeof dispatch];
Expand All @@ -106,15 +141,18 @@ export function useLocalSlice<State, Reducers extends ReducerMap<State>>({

const actionTypes = Object.keys(reducers);

const dispatchRef = useRef(dispatch);
dispatchRef.current = dispatch;

const dispatchAction = useMemo(() => {
let map: {
[actionType: string]: PayloadActionDispatch<{}>;
} = {};
for (const type of actionTypes) {
map[type] = (payload: any) => dispatch({ type, payload });
map[type] = (payload: any) => dispatchRef.current({ type, payload });
}
return map as DispatcherMap<Reducers>;
}, [dispatch, JSON.stringify(actionTypes)]);
}, [JSON.stringify(actionTypes)]);

return [state, dispatchAction];
}

0 comments on commit e995907

Please sign in to comment.