Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add zustand and persisted state management #64

Closed
wants to merge 11 commits into from
48 changes: 32 additions & 16 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@
"typescript-eslint": "^8.15.0",
"vite": "^5.4.11",
"vite-plugin-svgr": "^4.3.0",
"vitest": "2.1.8"
"vitest": "2.1.8",
"zustand": "5.0.2"
},
"overrides": {
"better-sqlite3": "11.5.0"
Expand Down
30 changes: 30 additions & 0 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { RouterProvider, createRouter } from '@tanstack/react-router'

import { usePersistedProjectIdStore } from './contexts/persistedState/PersistedProjectId'
import { useDeviceInfo } from './queries/deviceInfo'
import { routeTree } from './routeTree.gen'

export const router = createRouter({
routeTree,
context: { hasDeviceName: undefined!, persistedProjectId: undefined! },
})

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

export const App = () => {
const { data } = useDeviceInfo()
const hasDeviceName = data?.name !== undefined
const persistedProjectId = !!usePersistedProjectIdStore(
(store) => store.projectId,
)
return (
<RouterProvider
router={router}
context={{ hasDeviceName, persistedProjectId }}
/>
)
}
28 changes: 28 additions & 0 deletions src/renderer/src/AppWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ThemeProvider } from '@emotion/react'
import { CssBaseline } from '@mui/material'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

import { App } from './App'
import { theme } from './Theme'
import { ApiProvider } from './contexts/ApiContext'
import { IntlProvider } from './contexts/IntlContext'
import { PersistedProjectIdProvider } from './contexts/persistedState/PersistedProjectId'

const queryClient = new QueryClient()

export const AppWrapper = () => {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<IntlProvider>
<QueryClientProvider client={queryClient}>
<ApiProvider>
<PersistedProjectIdProvider>
<App />
</PersistedProjectIdProvider>
</ApiProvider>
</QueryClientProvider>
</IntlProvider>
</ThemeProvider>
)
}
8 changes: 4 additions & 4 deletions src/renderer/src/components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export const Tabs = () => {
<MapTabStyled
data-testid="tab-observation"
icon={<PostAddIcon />}
value={'/tab1'}
value={'/Tab1'}
/>
{/* This is needed to properly space the items. Originally used a div, but was causing console errors as the Parent component passes it props, which were invalid for non-tab components */}
<Tab disabled={true} sx={{ flex: 1 }} />
Expand All @@ -59,7 +59,7 @@ export const Tabs = () => {
{formatMessage(m.setting)}
</Text>
}
value={'/tab2'}
value={'/Tab2'}
/>
<MapTabStyled
icon={<InfoOutlinedIcon />}
Expand All @@ -68,7 +68,7 @@ export const Tabs = () => {
{formatMessage(m.about)}
</Text>
}
value={'/tab2'}
value={'/Tab2'}
/>
</MuiTabs>
)
Expand All @@ -77,7 +77,7 @@ export const Tabs = () => {
type TabProps = React.ComponentProps<typeof Tab>

type MapTabRoute = {
[K in keyof FileRoutesById]: K extends `${'/(MapTabs)/_Map'}${infer Rest}`
[K in keyof FileRoutesById]: K extends `${'/_Map'}${infer Rest}`
? Rest extends ''
? never
: `${Rest}`
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/src/contexts/persistedState/PersistedProjectId.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type StateCreator } from 'zustand'

import { createPersistedStoreWithProvider } from './createPersistedState'

type ProjectIdSlice = {
projectId: string | undefined
setProjectId: (id?: string) => void
}

const projectIdSlice: StateCreator<ProjectIdSlice> = (set) => ({
projectId: undefined,
setProjectId: (projectId) => set({ projectId }),
})

export const {
Provider: PersistedProjectIdProvider,
useStoreHook: usePersistedProjectIdStore,
Context: PersistedProjectIdContext,
nonPersistedStore: nonPersistedProjectIdStore,
} = createPersistedStoreWithProvider(projectIdSlice, 'ActiveProjectId')
62 changes: 62 additions & 0 deletions src/renderer/src/contexts/persistedState/createPersistedState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createContext, useContext, useState, type ReactNode } from 'react'
import { createStore, useStore, type StateCreator } from 'zustand'
import { persist } from 'zustand/middleware'

type PersistedStoreKey = 'ActiveProjectId'

/**
* Follows the pattern of injecting persisted state with a context. See
* https://tkdodo.eu/blog/zustand-and-react-context. Allows for easier testing
*/
export function createPersistedStoreWithProvider<T>(
slice: StateCreator<T>,
persistedStoreKey: PersistedStoreKey,
) {
const persistedStore = createPersistedStore(slice, persistedStoreKey)
// used for testing and injecting values into testing environment
const nonPersistedStore = createStore(slice)
// type persistedStore is a subset type of type nonPersistedStore
const Context = createContext<typeof nonPersistedStore | null>(null)

const Provider = ({ children }: { children: ReactNode }) => {
const [storeInstance] = useState(() => persistedStore)

return <Context.Provider value={storeInstance}>{children}</Context.Provider>
}

const useStoreHook = <Selected,>(
selector: (state: T) => Selected,
): Selected => {
const contextStore = useContext(Context)
if (!contextStore) {
throw new Error(
`Missing provider for persisted store: ${persistedStoreKey}`,
)
}

return useStore(contextStore, selector)
}

return { Provider, useStoreHook, Context, nonPersistedStore }
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm exposing the Context and the Provider here. The provider should be used by the app, the Context on the other hand can be used for testing to simulate the provider. This allows set up the same provider, without having to touch the persisted state, and injecting test values.

You can see an example of that here. The Value of persisted state determines where the user is redirected to. So I can easily simulate that state with a non persisted value by using the context and nonPersistedStore

There could be an argument that we should use persisted state in the tests, to better mimic the app. The problem is that we would be overwriting the actual persisted state, which might not be ideal for developer experience. We could use a different "key". Therefore testing environments would have its own persisted state, separate from the dev environment. This could get a little buggy as there could be things in persisted state that the test environment is using that we don't realize. We could make sure to clear persisted state at the end of every test file, but there would be some overhead with that.

@gmaclennan, @achou11, and @cimigree I'm curious if you have any insight or opinions of the best approach for testing persisted state.

}

function createPersistedStore<T>(
...args: Parameters<typeof createPersistMiddleware<T>>
) {
const store = createStore<T>()(createPersistMiddleware(...args))
store.setState((state) => ({
...state,
...args[0],
}))

return store
}

function createPersistMiddleware<State>(
slice: StateCreator<State>,
persistedStoreKey: PersistedStoreKey,
) {
return persist(slice, {
name: persistedStoreKey,
})
}
13 changes: 2 additions & 11 deletions src/renderer/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
import { RouterProvider, createRouter } from '@tanstack/react-router'
import { createRoot } from 'react-dom/client'

import { routeTree } from './routeTree.gen'
import { AppWrapper } from './AppWrapper'

import './index.css'

const router = createRouter({ routeTree })

declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}

const root = createRoot(document.getElementById('app') as HTMLElement)

root.render(<RouterProvider router={router} />)
root.render(<AppWrapper />)
Loading
Loading