frontend-state
MobX-based state management helpers for frontend projects.
Installation
yarn add @eventology/frontend-state
npm install @eventology/frontend-state
Guide
ItemStore
The core of this library is the ItemStore
class, which should serve as the single source of truth for any piece of loaded data.
Use the .add()
method to add an item to the store.
const users = new ItemStore<User>({ id: "userStore" })
const loadedUser = await fetchUser(userId)
users.add(loadedUser)
const user = users.get(someUserId)
if (user) {
console.log(user.id, user.name, user.bio)
}
.add()
can accept multiple items.
const followedUsers = await fetchFollowedUsers()
users.add(...followedUsers)
To update an item (e.g. for liking a post, or following a user, and having that show in the view), add a modified item with the same id. When an item is added, and the store already has another existing item with the same id, the item will be replaced.
users.add({ ...user, isFollowing: !user.isFollowing })
To remove an item (e.g. deleting a post), call .remove()
with the item ID.
users.remove(someUserId)
The Loader
and InfiniteListLoader
classes work in tandem with the ItemStore
. If an item is updated or removed from the store, the corresponding *Loader
s will update to reflect that.
Loader
Fetching data and storing it is simple enough, but the real complexities come with managing loading state, possible errors, and caching.
That's where Loader
comes in. The Loader
class accepts a function to load the data, then takes the data and stores it in a given ItemStore
.
Remember to use observer()
or useObserver()
from MobX, so the component re-renders when the properties update.
const users = new ItemStore<User>({ id: "userStore" })
const userLoader = new Loader<User>({
id: "userLoader",
store: users,
loadData: () => fetchUser(someUserId),
itemId: someUserId,
preloadedData: someUserData,
})
function UserDetailsContainer() {
useEffect(() => {
userLoader.lazyLoad()
}, [])
return useObserver(() => (
<>
{userLoader.isLoading && <LoadingSpinner />}
{userLoader.error && <ErrorMessage text={userLoader.error} />}
{userLoader.data && <UserDetails user={userLoader.data} />}
</>
))
}
Sometimes, you'll want to reload the data on demand, even if it's already loaded, e.g. for a refresh button. Use the .load()
method.
function UserRefreshButton() {
return <button onClick={userLoader.load}>Refresh User Data</button>
}
InfiniteListLoader
The powerful InfiniteListLoader
class simplifies loading data from a paginated API. It accepts a store
to store the loaded items into, and a loadPage
function, for loading each page.
The loadPage
function receives an object:
limit: number
- the number of items to fetchlastKey?: string
- the current "position" in the list, to load the next page after the current
The function must return:
items: T[]
- the loaded items for this page. T
is the generic type for the type of items the ILL is loading.lastKey?: string
- the last key for this page.
- If this is a string, it is stored in the ILL and passed to
loadPage
again to retrieve the next page of items. - If this is undefined, the list is considered "finished", and will not load any new items when
.loadMore()
is called.
loadPage
was made to seamlessly work with Fan Guru API endpoints, so if you're working with that, fitting this contract shouldn't be too difficult.
async function fetchPosts(params) {
const endpoint = `https://dev-api.fan.guru/v2/Feeds/Posts/all`
const response = await axios.get(endpoint, { params })
return response.data
}
const posts = new ItemStore<Post>({ id: "postStore" })
const postList = new InfiniteListLoader<Post>({
id: "postListLoader",
store: posts,
loadPage: fetchPosts,
})
Like Loader
, the InfiniteListLoader
also has a .lazyLoad()
method, to load items if the list hasn't loaded any yet. Use that in component effects.
It also has the following properties:
isEmpty
- true
when the list has no items, and has reached the end. Useful for displaying empty statesitems
- The items in the listitemIds
- The list of item IDs
function Posts() {
useEffect(() => {
postList.lazyLoad()
}, [])
return useObserver(() => (
<>
{postList.isLoading && <LoadingSpinner />}
{postList.error && <ErrorMessage text={postList.error} />}
{postList.isEmpty && <EmptyState />}
{postList.items.map(renderPost)}
</>
))
}
Then, you'll want to load more items in the list, for example, when the user scrolls down through the list. Use the loadMore()
method to load more items.
function Posts() {
return useObserver(() => (
<ScrollTracker onScrollToBottom={postList.loadMore}>
{postList.items.map(renderPost)}
</ScrollTracker>
))
}
For the cases where you have an item already loaded, and want it to show up in a specific list, use the .add()
method.
postList.add(newlyCreatedPost)
And to remove items, use .remove()
, with the item ID. Note that this will only remove the item from this list. To remove the item from the store, and from every list, use ItemStore.remove
instead.
postList.remove(deletedPost.id)
Note: The ILL does not store the items themselves, the store does (single source of truth!). items
is computed from itemIds
; each item ID is used to create an array of actual items from the given store.
If an item ID in the list does not exist in the store, it won't show up in items
. This way, when an item is removed from the store, it'll disappear from any list it's in. Similarly, when an item is updated in the store, it'll get updated in the list.
FactoryMap
Ideally, when we load something, we want to re-use the same loader object for that piece of data, instead of making a new one whenever we need to load it.
For example, when we go to a page in the app, navigate while it's loading, then navigate back, we don't want to reload the data. We either want to continue loading where we left off, or have the data already loaded once we return.
One way to solve this would be to create a map that acts as a cache for loaders, to ensure that a loader for any given userId
is created once and shared throughout the app.
const userLoaders = new Map<string, Loader<User>>()
function getUserLoader(userId: string) {
if (userLoaders.has(userID)) {
return userLoaders.get(userId)
}
const loader = new Loader<User>()
userLoaders.set(userId, loader)
return loader
}
However, you'll end up writing that code out a lot, and it gets even messier when you have multiple "keys" for each item (like filters, and ordering).
The FactoryMap
makes this easier.
const userLoaders = new FactoryMap((userId: string) => {
return new Loader<User>()
})
At its core, the FactoryMap
is a thin wrapper around Map
. When you call .get(...)
it tries to get an existing item and returns it. If the item doesn't exist, it creates a new one using the given factory function, hence the name.
To get an item, call .get()
with the specified key.
function UserDetails({ userId }) {
const loader = userLoaders.get(userId)
useEffect(() => {
loader.lazyLoad()
}, [loader])
}
That same loader for that same userId
will be reused for the lifetime of the application.
FactoryMap
supports functions accepting multiple arguments, which is helpful when you need loaders keyed by multiple params, e.g. for ordering and filtering. Here's an example with InfiniteListLoader
.
async function fetchPosts(params) {
const endpoint = `https://dev-api.fan.guru/v2/Feeds/Posts/all`
const response = await axios.get(endpoint, { params })
return response.data
}
function createPostList(postTypes: string, order: "score" | "new") {
return new InfiniteListLoader({
id: "postListLoader",
store: posts,
loadPage: (params) => fetchPosts({ ...params, postTypes, order }),
})
}
const posts = new ItemStore<Post>()
const postLists = new FactoryMap(createPostList)
function PostList() {
const posts = postLists.get("listing,album", "new")
useEffect(() => {
posts.lazyLoad()
}, [posts])
}
Best Practices
Creating and organizing stores - Root Store pattern
One good way to organize state with MobX is to use the root store pattern, described here.
Here's a trimmed example of a single store from fanguru web:
export default class PostStore {
posts = new ItemStore<Post>({ id: "postStore" })
postLoaders = new FactoryMap((postId: string) => {
return new Loader({
id: "postLoader",
store: this.posts,
loadData: () => getPostById(postId),
itemId: postId,
})
})
userPosts = new FactoryMap((userId: string) => {
return new InfiniteListLoader({
id: "userPostList",
store: this.posts,
loadPage: (params) => {
return request(`/users/${userId}/posts`, {
params: { ...params, postTypes: "album,snippet,video" },
})
},
})
})
}
Then you can create these stores in a single root store:
class RootStore {
postStore = new PostStore()
userStore = new UserStore()
}
Finally, make them available to the app using context:
const RootStoreContext = React.createContext(new RootStore())
function useRootStore() {
const store = useContext(RootStoreContext)
return store
}
const store = new RootStore()
ReactDOM.render(
<RootStoreContext.Provider value={store}>
<App />
</RootStoreContext.Provider>,
)
function App() {
const { postStore, userStore } = useRootStore()
const userPosts = postStore.userPosts.get(userStore.authUser.id)
}
For cross-store access, pass the root store's this
to the child stores.
class RootStore {
authStore = new AuthStore(this)
userStore = new UserStore()
}
class AuthStore {
@observable
userId?: string
constructor(private rootStore: RootStore) {}
@computed
get userLoader() {
return this.userId
? this.rootStore.userStore.userLoaders.get(this.userId)
: undefined
}
}
useLoader
hook
This useLoader
hook is a simple helper which calls .lazyLoad()
from an effect, making sure that for any loader in the component, the data will be loaded.
function useLoader(loader) {
useEffect(() => {
loader.lazyLoad()
}, [loader])
return loader
}
const postLoader = useLoader(postStore.postLoaders.get(props.postId))
Custom renderer components
Instead of manually using the *Loader
objects in components, you should create generic over them to simplify their use, and standardize loading/error states. For Loader
, that might look something like this:
type Props<T> = {
loader: Loader<T>
render: (data: T) => React.ReactNode
}
function LoaderView<T>({ loader, render }: Props<T>) {
useLoader(loader)
return (
<>
{loader.error != null && <ErrorMessage text={loader.error} />}
{loader.data != null && render(loader.data)}
{loader.isLoading && <LoadingState />}
</>
)
}
export default observer(LoaderView)
const { userStore } = useRootStore()
const userLoader = userStore.userLoaders.get(props.userId)
return (
<LoaderView
loader={userLoader}
render={(user) => <UserDetails user={user} />}
/>
)
This library does not come with any renderer or "view" components. It's best for any app to come up with whatever works best for that app (especially with the differences between web and native).
FactoryMap: use options objects
FactoryMap supports multiple arguments for the factory function, but this can quickly get messy and confusing:
const map = new FactoryMap(
(firstName: string, lastName: string, id: string) => {
return new Loader({
id: "userLoader",
loadData: () => loadUser({ firstName, lastName, id }),
})
},
)
const loader = map.get(id, firstName, lastName)
When a factoryMap starts to accept more than two arguments, that's a good time to use an options object instead:
type UserLoaderParams = {
firstName: string
lastName: string
id: string
}
const map = new FactoryMap((params: UserLoaderParams) => {
return new Loader({
id: "userLoader",
loadData: () => loadUser(params),
})
})
const loader = map.get({ id, firstName, lastName })
Related data
Sometimes, you'll receive some related data attached to the objects you're interested in, say, a post
has an author
on it, which is the related user. That property might have some newer user data on it than what's already stored.
To solve this, ItemStore
has an onItemAdded
option, which will allow the app to perform actions after storing items. This can be used to update related data:
const userStore = new ItemStore<User>({
id: "userStore",
})
const postStore = new ItemStore<Post>({
id: "postStore",
onItemAdded: (post) => {
userStore.add(post.author)
},
})
const postLoaders = new FactoryMap((postId: string) => {
return new Loader({
id: "postLoader",
itemId: postId,
loadData: () => loadPost(postId),
})
})