Tere, tere, tere! In this post I will go through my case of development a global state management solution. Nothing ground breaking, just the flow of developing a suitable API for my case.
First of all, if you're unfamiliar with React Context and React Hooks, it's time to become friends with them. Also I can honestly say that even after some time of using Hooks, I still have a tab with Hooks documentation pinned (it gets updated!).
Problem statement
Knowing that all other projects started with Redux straight away, I decided to postpone writing boilerplate, creating many files, adding reselect to "dependencies". I wanted something Stupid Simple.
Before Hooks
I started without Hooks first, as I was afraid they won't be released before my product has to be released, so I had pretty straightforward setup:
const context = createContext({field: null});
class DataProvider extends Component {
state = {
field: null,
otherField: { default: 'I am default' }
}
getField = async () => {
const field = await api.getField();
this.setState({ field });
}
render() {
const value = { data: this.state, getField: this.getField };
return <context.Provider value={value}>{ this.props.children }</context.Provider>
}
}
This is a common approach, nothing special here.
I will often point out that I don't do premature performance optimisations. Like here we create new value every time
field
updates, so even if some consumer doesn't usedata.field
it still gets updated. But is it a problem?
Later, I had some contexts, which operated with more data and had more actions, so I separated context value to { data, api }
(by the way it was good challenge to separate stores to a simple modules with small responsibility area!).
So my consumers looked like this:
const Consumer = ({ title }) => {
return (
<BooksConsumer>
{({ data: { books }, api: { getBooks } }) => (
<AuthorsConsumer>
{({ data: authors }) => <OurComponent title={title} books={books} getBooks={getBooks} authors={authors} />}
</AuthorsConsumer>
)}
</BooksConsumer>
);
};
Okay, looks ugly. And also for each context I had to repeat all the boilerplate of creation. Then I heard about hooks released soon and decided to go all in. At least to get rid of ugly (I don't think so) render functions nesting.
const Consumer = ({ title }) => {
const {
data: { books },
api: { getBooks },
} = useContext(BooksContext);
const {
data: { authors },
} = useContext(AuthorsContext);
return <OurComponent title={title} books={books} getBooks={getBooks} authors={authors} />;
};
I have
Consumer
s wrappers around components for easier testing.
So the next problem was the boilerplate I had to write for each context. And as one smart guy said, we want to move our dynamic pattern to a function. The first idea was to simply have something like this:
function createStore(data, api) {
const context = createContext(state);
const Provider = ({ children }) => {
const [state, updateState] = useState(data);
const value = {
data: state,
api: wrapApi(api, updateState, state),
};
return <context.Provider value={value}>{children}</context.Provider>;
};
return [context, Provider];
}
function wrapApi(api, updateState, state) {
return Object.keys(api).reduce((res, key) => {
res[key] = api[key](updateState, state);
return res;
}, {});
}
So that the usage of it can be:
const api = {
getBooks: (updateState, state) => async () => {
const books = await fetchBooks();
updateState({ ...state, books });
},
};
const state = {
books: [],
notBooks: []
}
export const [BooksContext, BooksProvider] = createStore(state, api);
Now, every time we need a new context - we use one function with a simple API. Notice, that I put updateState, state
into the closure of our api, so that our components can easily just call functions from api
.
Fun fact:
api
name was chosen because it short, and not because it makes API requests 🤷🏻♂️.
Again, you might be tempting to make "optimisations":
const Provider = ({ children }) => {
const [state, updateState] = useState(data);
const value = useMemo(
() => ({
data: state,
api: wrapApi(api, updateState, state),
}),
[state, updateState],
); // this
return <context.Provider value={value}>{children}</context.Provider>;
};
But of course it won't make things better, state will change (as we want it to), so we will need to create new value again and bind API again.
Okay, it actually seems enough already. But I wanted some debugging experience. Not that I miss redux-dev-tools or anything... I decided to go with reducer now and add some powerful debugging capabilities (console.log
):
function createStore(reducer, actions, initialState) {
const context = createContext(initialState);
const Provider = ({ children }) => {
const [data, dispatch] = useReducer(reducer, initialState);
const actionCreators = bindActionCreators(actions, dispatch, data); // bind same way as before but add console.log 😅
return <context.Provider value={[data, actionCreators]}>{children}</context.Provider>;
};
return [context, Provider];
}
So I could use it as:
function reducer(state, { action, payload }) {
switch (action) {
case SET_BOOKS: {
return { ...state, books: payload };
}
default: {
return state;
}
}
}
const api = {
getBooks: (dispatch, state) => async () => {
const books = await fetchBooks();
dispatch({ action: SET_BOOKS, payload: books });
},
};
const defaultState = {
books: [],
notBooks: [],
};
const [BooksContext, BooksProvider] = createStore(reducer, api, defaultState);
Oh mama... Looks like Redux :c
Let's simplify a bit:
function createStore(reducer, actions, initialState) {
const context = createContext(initialState);
const Provider = ({ children }) => {
const reducerFn = (state, { type, payload }) => reducer[type](state, payload); // we create reducer here instead
const [data, dispatch] = useReducer(reducerFn, initialState);
const actionCreators = bindActionCreators(actions, dispatch, data);
return <context.Provider value={[data, actionCreators]}>{children}</context.Provider>;
};
return [context, Provider];
}
And the usage would be:
const reducer = {
[SET_BOOKS]: (state, payload) => ({ ...state, books: payload }), // or just `books` as param
};
const actions = {
getBooks: (dispatch, state) => async () => {
const books = await fetchBooks();
dispatch({ action: SET_BOOKS, payload: books });
},
};
const [BooksContext, BooksProvider] = createStore(reducer, actions);
// component
const Consumer = () => {
const [{ books }, { getBooks }] = useContext(BooksContext);
// ...usage
}
Looks better. Especially with TypeScript: I have secure actions
, so that you can dispatch only enum Actions
which then used in reducer. But why is it better than using redux? It's not, but.
Usually, you would import some helper functions from state library, like connect
form react-redux
and yours action creators (maybe even with redux-thunk
). Then you map your state and dispatchers to props. With current API I can just select what I need already from context, knowing what it is and where it comes from (single source), no need for file-traveling (if you don't use redux ducks). I only needed to do something... Write own Hook!
useStore
function createStore<D, A extends StoreActions>(
reducer: StoreReducer<D, any>,
actions: A,
initialState?: D,
): Store<D, A> {
const context = createContext<ContextValue<D, A>>([initialState, actions ? ({} as BoundStoreActions<A>) : null]);
const StoreProvider: FunctionComponent = ({ children }) => {
// create Reducer, use Reducer, bind actions
const store = useStoreProvider(reducer, actions, initialState);
return <context.Provider value={store}>{children}</context.Provider>;
};
const useStore: useStore<D, A> = selector => {
const [data, actions] = useContext(context);
// map state and actions to anything you need (object, array, just one field)
const selected = selector(data, actions);
return selected;
};
return [context, StoreProvider, useStore];
}
And then in my component:
const BooksList = ({ authorId ) => {
const { books, setFavorite } = useBooks((store, actions) => ({
books: store.books.filter(book => book.authorId === authorId), // feels like selector, isn't it? 🙃
setFavorite: actions.setFavorite,
}));
const authorInfo = useAuthors(store => store.authors[authorId]);
return (
<List>
{books.map(book => (
<Book key={book.id} title={book.title} author={authorInfo} />
))}
</List>
);
};
Pretty neat, isn't it?
Why I put actions to the store? It means that all my actions have to be bound every time store changes, why not just use them as normal functions, passing them to component? Like this:
// modules/books
const [BooksContext, BooksProvider, useBooks] = createStore(reducer, initialState);
// components/Books
import {getBooks, getAuthors, useBooks } from 'modules/books'
const Books = () => {
const {books, getBooks, getAuthors } = useBooks(store => ({ books: store.data.books, getBooks, getAuthors});
// I wrote getBooks, getAuthors 3 times :/
// use it
So what could be done there is passing actions that I want to bind to a store and bind them in useBooks
. It means that it can be any action, even unrelated to a store and hence can dispatch action type that doesn't exist in store. But at the same time it can allow to have reusable actions fro different stores, which is rare case. Also, store creation is easier now, no need to bind anything during creation. But the main thing here is that you have to import actions which have names and them take them from useBooks
with either same name (which can be confusing) or make up a new name for them (which is soo tough).
What's next?
The thing is I'm already quite happy with the current solution (not types, btw, seems like I have to learn how to write them properly). It works, there is no performance issues with 9+ stores with different kind of data and purposes. But I'd like to hear your opinions and solutions, take a look at the source code, it has second proposal with example. If it's something you'd like to use and improve - you're welcome! Then I can continue working on it, add tests, examples of usage, maybe performance tweaks, like selectors, etc (not that I won't working on it, but anyway...)!
So how would you created your global stores?
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.