4.0.0 • Published 3 years ago

@eventology/frontend-state v4.0.0

Weekly downloads
67
License
MIT
Repository
github
Last release
3 years ago

frontend-state

MobX-based state management helpers for frontend projects.

API Docs

Changelog

Installation

# with yarn
yarn add @eventology/frontend-state

# with npm
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" })

// loading and adding an item to the store
const loadedUser = await fetchUser(userId)
users.add(loadedUser)

// get an item later
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 *Loaders 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" })

// This is only an example. Creating single loaders like this is uncommon.
// See the `FactoryMap` section for the ideal usage
const userLoader = new Loader<User>({
  id: "userLoader",
  store: users,
  loadData: () => fetchUser(someUserId),

  // If you already know the item ID ahead of time, specify it.
  // The Loader will try to pull the data from the store by that ID if it already exists,
  // to prevent unnecessarily reloading it later
  itemId: someUserId,

  // If you have the data, just not the ID, use the preloadedData option.
  // The loader will take this data and put it in the store
  preloadedData: someUserData,
})

// example for rendering data
function UserDetailsContainer() {
  useEffect(() => {
    // .lazyLoad() only loads the data if it hasn't been loaded
    // this is what you'll want to do most of the time from an effect,
    // to make sure there is data available to render
    userLoader.lazyLoad()
  }, [])

  // here we use useObserver(),
  // but wrapping this function component in `observer()` would also work
  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 fetch
  • lastKey?: 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 states
  • items - The items in the list
  • itemIds - 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() {
  // the ScrollTracker is an imaginary component which calls `onScrollToBottom`
  // when the user has reached the bottom of the view
  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()

    // note that the loader may change between renders,
    // so to make sure the data gets loaded when it does,
    // we have to specify it as a dependency here
  }, [loader])

  // render data, error, etc.
}

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])

  // render items, error, etc.
}

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())

// helper hook to use root store from context
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)
  // etc.
}

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
}
// usage
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)
// usage
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 }),
    })
  },
)

// argument order is unclear, this will typecheck but silently fail at runtime!!
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),
  })
})

// perfectly clear!
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) => {
    // whenever any post gets added to the store,
    // add the post's author object to the userStore,
    // replacing whatever's already there with the same id,
    // effectively updating it
    userStore.add(post.author)
  },
})

const postLoaders = new FactoryMap((postId: string) => {
  return new Loader({
    id: "postLoader",
    itemId: postId,
    loadData: () => loadPost(postId),
  })
})
4.0.0

3 years ago

3.0.0

3 years ago

2.0.1

4 years ago

2.0.0

4 years ago

1.1.0

4 years ago

1.0.1

4 years ago

1.0.0

4 years ago

0.10.0

5 years ago

0.9.0

5 years ago

0.8.0

5 years ago

0.7.2

5 years ago

0.7.1

5 years ago

0.7.0

5 years ago

0.6.0

5 years ago

0.5.1

5 years ago

0.5.0

5 years ago

0.4.0

5 years ago

0.3.0

5 years ago

0.2.0

5 years ago

0.1.1

5 years ago

0.1.0

5 years ago