Skip to content

Commit

Permalink
perf: use zustand for snackbars
Browse files Browse the repository at this point in the history
  • Loading branch information
lordspace74 committed Mar 16, 2023
1 parent eb58d4b commit 93c7e42
Show file tree
Hide file tree
Showing 4 changed files with 192 additions and 3 deletions.
6 changes: 6 additions & 0 deletions src/components/SnackbarPresentationView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ export type SnackbarPresentationViewProps = {
readonly colorize?: boolean
}

/**
* This component should be placed where you want the snackbars to be shown.
*
* Do NOT use this component if you're using the hooks from `useSnackbar`,
* this is to be used with `SnackbarContext`!
*/
export const SnackbarPresentationView: React.FC<SnackbarPresentationViewProps> = ({
Component = DefaultSnackbarComponent,
isVisibleToUser = true,
Expand Down
9 changes: 6 additions & 3 deletions src/contexts/Snackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import getRandomID from '../utils/getRandomID'

import type { PropsWithChildren } from 'react'

export const DEFAULT_SNACKBAR_TIMOUT_MS = 5000
export const DEFAULT_SNACKBARS_TO_SHOW_AT_SAME_TIME = 1

export type Action = {
readonly key?: string,
readonly label: string,
Expand All @@ -22,7 +25,7 @@ export type SnackbarConfig<TMap extends Record<string, unknown> = Record<string,
readonly data?: TMap[T],
}

type SnackbarWithId = {
export type SnackbarWithId = {
readonly snackbarConfig: SnackbarConfig,
readonly id: string,
}
Expand Down Expand Up @@ -55,8 +58,8 @@ let hasWarned = false

export const SnackbarProvider: React.FC<SnackbarProviderProps> = ({
children,
defaultTimeoutMs = 5000,
snackbarsToShowAtSameTime = 1,
defaultTimeoutMs = DEFAULT_SNACKBAR_TIMOUT_MS,
snackbarsToShowAtSameTime = DEFAULT_SNACKBARS_TO_SHOW_AT_SAME_TIME,
}) => {
const [snackbars, setSnackbars] = useState<readonly SnackbarWithId[]>([])
const timeouts = useRef(new Map<string, number>())
Expand Down
4 changes: 4 additions & 0 deletions src/hooks/useAddSnackbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { SnackbarContext } from '../contexts/Snackbar'

import type { SnackbarConfig } from '../contexts/Snackbar'

/**
* **Note!** This hook is to be used in conjunction with the Snackbar context, use
* `useAddSnackbar` from `src/hooks/useSnackbar.tsx` if you are not using the Snackbar context.
*/
export function useAddSnackbar<TMap extends Record<string, unknown> = Record<string, unknown>, T extends keyof TMap = keyof TMap>(defaultSnackbarConfig?: Omit<SnackbarConfig<TMap, T>, 'title'>) {
const { addSnackbar } = useContext(SnackbarContext)

Expand Down
176 changes: 176 additions & 0 deletions src/hooks/useSnackbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React, { useCallback, useEffect } from 'react'
import { View } from 'react-native'
import { create } from 'zustand'

import { DefaultSnackbarComponent } from '../components'
import { DEFAULT_SNACKBARS_TO_SHOW_AT_SAME_TIME, DEFAULT_SNACKBAR_TIMOUT_MS } from '../contexts/Snackbar'
import { randomHexColorAlpha } from '../utils'
import getRandomID from '../utils/getRandomID'

import type { SnackbarComponentProps } from '../components/SnackbarComponent'
import type { AddSnackbarFn, SnackbarWithId, SnackbarConfig } from '../contexts/Snackbar'
import type { StyleProp, ViewStyle } from 'react-native'

export * from '../components/SnackbarComponent'

interface SnackbarStore {
readonly defaultTimeoutMs: number
readonly setDefaultTimeoutMs: (timeout: number | undefined) => void
readonly snackbarsToShowAtSameTime: number
readonly setSnackbarsToShowAtSameTime: (value: number | undefined) => void

readonly snackbars: readonly SnackbarWithId[]
readonly snackbarsToShow: readonly SnackbarWithId[]

readonly addSnackbar: AddSnackbarFn
readonly removeSnackbar: (id: string) => void
readonly snackbarWasPresented: (id: string) => void
}

let hasWarned = false
const timeouts = new Map<string, number>()

const useSnackbarStore = create<SnackbarStore>((set) => ({
defaultTimeoutMs: DEFAULT_SNACKBAR_TIMOUT_MS,
setDefaultTimeoutMs: (timeout) => set(() => ({ defaultTimeoutMs: timeout ?? DEFAULT_SNACKBAR_TIMOUT_MS })),
snackbarsToShowAtSameTime: DEFAULT_SNACKBARS_TO_SHOW_AT_SAME_TIME,
setSnackbarsToShowAtSameTime: (value) => set((state) => {
const snackbarsToShowAtSameTime = value ?? DEFAULT_SNACKBARS_TO_SHOW_AT_SAME_TIME

return {
snackbarsToShow: state.snackbars.slice(0, snackbarsToShowAtSameTime),
snackbarsToShowAtSameTime,
}
}),
snackbars: [],
snackbarsToShow: [],
addSnackbar: (snackbarConfig) => set((state) => {
const snackbars = [
...state.snackbars,
{
snackbarConfig: {
...snackbarConfig,
type: snackbarConfig.type as never, // here is where type safety ends
data: snackbarConfig.data as never,
},
id: snackbarConfig.id || getRandomID(),
},
]

return {
snackbars,
snackbarsToShow: snackbars.slice(0, state.snackbarsToShowAtSameTime),
}
}),
removeSnackbar: (id: string) => set((state) => {
const snackbars = state.snackbars.filter((s) => s.id !== id)

return {
snackbars,
snackbarsToShow: snackbars.slice(0, state.snackbarsToShowAtSameTime),
}
}),
snackbarWasPresented: (id: string) => set((state) => {
const snackbar = state.snackbars.find((s) => s.id === id)

if (!timeouts.has(id)) {
snackbar?.snackbarConfig.onShow?.()
timeouts.set(id, setTimeout(() => {
state.removeSnackbar(id)
timeouts.delete(id)
}, snackbar?.snackbarConfig.timeout || state.defaultTimeoutMs) as unknown as number)
}

if (!hasWarned) {
setImmediate(() => {
if (timeouts.size === 0) {
// eslint-disable-next-line no-console
console.warn('[@kingstinct/react] Snackbar added but not shown, make sure SnackbarView is present (or that you\'re calling snackbarWasPresented if rolling your own).')
hasWarned = true
}
})
}

return {}
}),
}))

export type SnackbarPresentationViewProps = {
readonly Component?: React.FC<SnackbarComponentProps>,
readonly style?: StyleProp<ViewStyle>
readonly isVisibleToUser?: boolean,
readonly colorize?: boolean
}

/**
* This component should be placed where you want the snackbars to be shown.
*
* Do NOT use this component if you're using SnackbarContext!
*/
export const SnackbarPresentationView: React.FC<SnackbarPresentationViewProps> = ({
Component = DefaultSnackbarComponent,
isVisibleToUser = true,
style,
colorize,
}) => {
const snackbarWasPresented = useSnackbarWasPresented()
const snackbarsToShow = useSnackbarsToShow()
const removeSnackbar = useRemoveSnackbar()

useEffect(() => {
if (isVisibleToUser) {
snackbarsToShow.forEach((snackbar) => snackbarWasPresented(snackbar.id))
}
}, [snackbarsToShow, snackbarWasPresented, isVisibleToUser])

return (
<View
pointerEvents='box-none'
style={[style, { backgroundColor: colorize ? randomHexColorAlpha() : undefined }]}
>
{ snackbarsToShow.map((i, index) => (
<Component
doDismiss={removeSnackbar}
key={i.id}
id={i.id}
snackbarConfig={i.snackbarConfig}
index={index}
/>
)) }
</View>
)
}

export interface SnackbarSettings {
/** Default value is 5000 ms */
readonly defaultTimeoutMs?: number
/** Default value is 1 */
readonly snackbarsToShowAtSameTime?: number
}

export const useSnackbarSettings = (settings: SnackbarSettings) => {
const setDefaultTimeoutMs = useSnackbarStore((state) => state.setDefaultTimeoutMs)
const setSnackbarsToShowAtSameTime = useSnackbarStore((state) => state.setSnackbarsToShowAtSameTime)

useEffect(() => {
if (settings.defaultTimeoutMs != null) setDefaultTimeoutMs(settings.defaultTimeoutMs)
}, [setDefaultTimeoutMs, settings.defaultTimeoutMs])

useEffect(() => {
if (settings.snackbarsToShowAtSameTime != null) setSnackbarsToShowAtSameTime(settings.snackbarsToShowAtSameTime)
}, [setSnackbarsToShowAtSameTime, settings.snackbarsToShowAtSameTime])
}

export function useAddSnackbar<TMap extends Record<string, unknown> = Record<string, unknown>, T extends keyof TMap = keyof TMap>(defaultSnackbarConfig?: Omit<SnackbarConfig<TMap, T>, 'title'>) {
const addSnackbar = useSnackbarStore((state) => state.addSnackbar)

return useCallback(function ShowSnackbar<TMapInner extends Record<string, unknown> = TMap, TInner extends keyof TMapInner = keyof TMapInner>(title: string, snackbarConfig?: Omit<SnackbarConfig<TMapInner, TInner>, 'title'>) {
addSnackbar<TMapInner, TInner>({ ...defaultSnackbarConfig, ...snackbarConfig, title } as SnackbarConfig<TMapInner, TInner>)
}, [addSnackbar, defaultSnackbarConfig])
}

export const useSnackbarWasPresented = () => useSnackbarStore((state) => state.snackbarWasPresented)

export const useSnackbarsToShow = () => useSnackbarStore((state) => state.snackbarsToShow)

export const useRemoveSnackbar = () => useSnackbarStore((state) => state.removeSnackbar)

0 comments on commit 93c7e42

Please sign in to comment.