README
🧸 Zfy
Useful helpers for state management in React with zustand.
💫 Acknowledgments
As you can imagine: major thanks to the team of contributors behind zustand for such an amazing library!
Zfy also exists thanks to the folks working on Immer who made it so easy to deal with immutability.
🌺 Features
- Fully typed with TypeScript
- Harmonized access/update API
- Logger & persistence middlewares provided out-of-the-box
- Easy-to-use persist gate component & rehydration hook
- Support for lazy and eager store rehydration
- Persistence compatible with any storage library
- Simple API for store creation with custom middlewares
🏗️ Installation
yarn add @colorfy-software/zfy
💻 Usage
import {
initZfy,
createStore,
useRehydrate,
PersistGate,
} from "@colorfy-software/zfy";
// 1. Initialize the library.
initZfy({
storage: AsyncStorage,
persistKey: 'myAppPersistKey',
})
// 2. Create a store.
const userStore = createStore('user', { firstName: 'User' }, { persist: { lazyRehydration: true }})
// 3. Use the store inside your components.
const Component = (): JSX.Element => {
const firstName = userStore(({ data }) => data.firstName)
}
// 4. Or use it outside of React.
const userName = userStore.getState().data.firstName
// 5. Update it from wherever.
userStore.getState().update((user) => {
user.data.firstName = 'Teddy'
})
// 6. And reset it all when you're done.
userStore.getState().reset()
initZfy(config)
Read section
initZfy()
is the function that configures the library. You should call it as early as possible in your code, in your
root index.ts/js
file for example.
Example:
// index.ts
import { initZfy, ZfyConfigType } from '@colorfy-software/zfy'
import AsyncStorage from '@react-native-async-storage/async-storage'
const config: ZfyConfigType = {
enableLogging: true,
storage: AsyncStorage,
persistKey: 'myAppName',
}
initZfy(config)
// ...
Check ZfyConfigType to see all the supported configuration options.
Notes:
storage
only needs your persistent storage solution to provide getItem()
& setItem()
functions, wether they are Promises or not. If it doesn't, you can simply implement it yourself. Eg:
storage: {
setItem: (key, data) => realm.write(/* whatever you want to do here */)
}
createStore(name, data, options)
Read section
createStore()
, you guessed it, is the function that creates a zustand store. It only expects the store name & its
default data but you can also provide some options.
That's where you can enable the middlewares Zfy provides out-of-the-box, like persist
or logger
, or provide your own via the customMiddlewares option.
⚠️ Zfy puts a special twist on how the resulting stores can be used, which is explained in the Using a Zfy store section.
Example:
// src/stores/user-store.ts
import { createStore } from '@colorfy-software/zfy'
import type { UserType, StoresDataType } from '../types'
export const initialState: UserType = {
id: '',
likes: 0,
}
export default createStore<StoresDataType, 'user'>('user', initialState, {
log: true,
persist: { lazyRehydration: true },
})
Check CreateStoreOptionsType to see all the supported options.
useRehydrate(stores)
Read section
useRehydrate()
is a React hook that rehydrates all the persisted stores you provide to it and returns true
once
that's done.
Example:
// src/App.tsx
import { useEffect } from 'react'
import { useRehydrate } from '@colorfy-software/zfy'
import SplashScreen from 'my-splash-screen-library'
import Navigation from './navigation'
import user from './stores/user-store.ts'
import settings from './stores/settings-store.ts'
export default (): JSX.Element => {
const isRehydrated = useRehydrate({ user, settings })
useEffect(() => {
if (isRehydrated) SplashScreen.hide()
}, [isRehydrated])
return <Navigation />
}
Notes:
Each key of the
stores
object you passed touseRehydrate()
has to match thename
argument you provided tocreateStore()
when creating its store. Without this, rehydration will not happen asuseRehydrate()
won't be able to tell which store you're providing.It's also very important here that: you do not try to export all the stores from single file before importing them to use in another!
Let's assume we have:
// src/stores/index.ts
import user from './user-store.ts'
import settings from './settings-store.ts'
export default { user, settings }
if we were to write the following for instance:
// src/App.tsx
import { useRehydrate } from '@colorfy-software/zfy'
import stores from './stores'
export default (): JSX.Element => {
const isRehydrated = useRehydrate(stores)
// ...
}
We could end up in situations were, by the time useRehydrate()
is trying to access the user
store for instance, it
could still be undefined
as: the store wouldn't have been created yet.
That's why we highly recommend that you directly import stores from the file were you created them, before providing
them to useRehydrate()
.
<PersistGate stores={stores} loader={<Loader />} />
Read section
<PersistGate />
is the component equivalent of useRehydrate()
(that it still uses under the hood). You can use it to display a loader in your app while your stores are being rehydrated.
Example:
// src/App.tsx
import { useEffect } from 'react'
import { PersistGate } from '@colorfy-software/zfy'
import Loader from './Loader'
import Navigation from './navigation'
import user from './stores/user-store.ts'
import settings from './stores/settings-store.ts'
export default (): JSX.Element => (
<PersistGate stores={{ user, settings }} loader={<Loader/>}>
<Navigation />
</PersistGate>
)
Notes:
⚠️ The same warning notes as with useRehydrate()
apply here too.
Using a Zfy store
We decided at colorfy to follow a specific set of rules that dictate how we use zustand stores. They trickled down into some aspects of Zfy's API that we would like to explain here.
It's important to note that: all the default zustand features are still working as you'd expect, Zfy only aims at enhancing not replacing them.
⚠️ So even though they're recommendations more than requirements, disregarding the points covered below will prevent Zfy from showcasing its full potential (and will potentially lead to undesired bugs).
Read section
How to access data?
As you may have realised by looking into
createStore()
: the data you want to use
and display in your app
is explicitly put inside getState().data
and only there.
So no matter which type of data you want to put inside a
store: it will always be available from getState().data
, not the top level getState()
.
This might sound quite restrictive or overdoing it at first but, such approach helped us tremendously by simplifying and harmonizing how stores are created and used throughout the entire codebase. It also allowed us to implement rehydration in a more flexible and scalable way.
That's why:
Any store created with Zfy always exposes the same 4 elements from the
getState()
object:data
,rehydrate()
,update()
&reset()
.
If you're using persistence with lazy rehydration explicitly, isRehydrated
is added as the 5th one but is mainly
used by Zfy rehydration tools.
By this logic, accessing your data will always look the same. Eg:
// src/MyRootComponent.tsx
import shallow from 'zustand/shallow'
import userStore from './stores/user-store'
import settingsStore from './stores/settings-store'
const MyRootComponent = (): JSX.Element => {
const appLanguage = settingsStore(({ data }) => data.language)
const [firstName, lastName] = userStore(({ data }) => [data.firstName, data.lastName], shallow)
// ...
}
If you're outside of React, you can still switch to the vanilla API. Eg:
import messagesStore from './stores/messages-store'
const amountOfUnreadMessages = messagesStore.getState().data.unread.length
Now that we've covered how to access data with Zfy, you may ask:
How to update data?
As we briefly saw earlier, Zfy exposes 3 methods for updating a store, rehydrate()
, update()
& reset()
, with
each having a specific use case you could guess by their name:
rehydrate()
is the method you will probably use the least as it's primarily meant for Zfy itself. If you enable persistence when creating a store: that's the method the library will call to properly rehydrate it when you're usinguseRehydrate()
or<PersistGate />
. But for it to work as expected, as explained in the next subsection, Zfy expects you to update your data solely viaupdate()
, notsetState()
!update()
is the method you'll be using the most as that's howdata
is being changed. And thanks to our use of Immer, you won't have to think about actions, reducers, immutability or anything else. Just update your data, even with mutable update patterns, Immer will take care of the rest. Eg:Using from outside React:
// src/core.js import Auth from 'my-auth-provider' import Api from '../api' import userStore from '../stores/user-store' import messagesStore from '../stores/messages-store' const updateUser = userStore.getState().update const updateMessages = messagesStore.getState().update export default { user: { login: async (email, password) => new Promise((resolve, reject) => { try { const userData = await Auth.login(email, password) updateUser((user) => { user.data = userData }) resolve(userData) } catch (error) { reject(error) } }) }, messages: { fetchInbox: async () => new Promise((resolve, reject) => { try { if (!(await Auth.isLoggedIn())) return resolve(messagesStore.getState().data.inbox) const inboxMessages = await Api.fetchInbox() updateMessages((messages) => { messages.data.inbox = inboxMessages }) resolve(inboxMessages) } catch (error) { reject(error) } }), markAsRead: async (messageId) => new Promise((resolve, reject) => { try { if (!(await Auth.isLoggedIn())) return resolve(false) await Api.markMessageAsRead(messageId) updateMessages((messages) => { const index = messages.data.inbox.findIndex(item => item.id === messageId) if (index !== -1) { messages.data.read.unshift({ ...messages.data.inbox[index], readAt: date.now() }) messages.data.inbox.splice(index, 1) } }) resolve(true) } catch (error) { reject(error) } }) } }
Using from within React works the same:
// src/screens/Login.tsx import core from '../core' import navigation from '../navigation' import appInfoStore from '../stores/app-info-store' const updateAppInfo = appInfoStore.getState().update const Login = (): JSX.Element => { const onPressLogin = async (email, password) => { try { // ... await core.user.login(email, password) updateAppInfo((appInfo) => { appInfo.data.lastLoginAt = Date.now() }) await core.messages.fetchInbox() navigation.to('Home') } catch (error) { // handle error } finally { // ... } } // ... } export default Login
Note that if you've enabled the provided persistence middleware on a store,
update()
will automatically take care of savingdata
in a way that will allowrehydrate()
to work without you having to do anything. That's why:
⚠️ For rehydration to work as expected, you should never use
setState()
but onlygetState().update()
.
reset()
finally, is your go-to method when you simply want to reset your store to its initial default data, useful for when your users are logging out for instance:// src/core.js import Auth from 'my-auth-provider' import userStore from '../stores/user-store' const resetUser = userStore.getState().reset export default { user: { logout: async (email, password) => new Promise((resolve, reject) => { try { await Auth.logout() resetUser() resolve(true) } catch (error) { reject(error) } }) }, }
// src/screens/Profile.tsx import core from '../core' import navigation from '../navigation' const Profile = (): JSX.Element => { const onPressLogout = async () => { try { // ... await core.user.logout() navigation.reset('Login') } catch (error) { // handle error } finally { // ... } } // ... } export default Profile
🤝 Contributing
This library is a very opinionated approach to using zustand that the team uses at colorfy.
Therefore, we won't necessarily consider requests that do not align with our goals/vision/use cases for Zfy.
However, feel free to voice your opinions if need be: our position might change!
You can also consider doing so from the inside 👀…
See the contributing guide to learn how to contribute to the repository and the development workflow.
📰 License
MIT