In the second week, I briefly defined the theme of the app (which is mainly about colors) and set up the app's database to build the display interface using the data.
Theme System#
When I initialized the project, I chose Tamagui, but when I started customizing the theme system, I found the documentation confusing 😵💫. In addition, its comprehensive style made me switch to Unistyles.
Its theme system is just a regular object, and all I need to do is pass my favorite Radix Color to it. Unlike Tailwind's color scheme, it has designed corresponding dark colors for each color, making it very simple to support dark themes.
export const lightTheme = {
colors: {
...accent,
...accentA,
},
} as const
export const darkTheme = {
colors: {
...accentDark,
...accentDarkA,
},
} as const
Because there are quite a few colors to pass, it's easy to forget to add the corresponding theme to the dark theme. This can be constrained by type checking. Refer to the article How to test your types.
type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
type _ExpectLightAndDarkThemesHaveSameKeys = Expect<Equal<
keyof typeof lightTheme.colors,
keyof typeof darkTheme.colors
>>
In addition, you can easily modify the theme using its runtime, making it very simple to write dynamic theme switching like the one below.
UnistylesRuntime.updateTheme(
UnistylesRuntime.themeName,
oldTheme => ({
...oldTheme,
colors: {
...oldTheme.colors,
...accent,
...accentA,
...accentDark,
...accentDarkA,
},
}),
)
Local First#
If it's a web page, it's reasonable not to do Local First. As an environment that can run SQLite, there's no reason why the app can't be opened in an offline environment. Currently, my idea is for the app to mainly interact with the local database and use network requests for data synchronization.
Regarding the technology stack, I chose drizzle without hesitation for the following reasons:
- The server-side I'm currently following is also using it, and I can even copy many table definitions.
- Compared to libraries like Prisma that use code generation for type definitions, I prefer to write table definitions in ts to have instant type refreshing.
- The integration recommended by Expo official documentation with Expo SQLite is drizzle, while Prisma integration is still in the Early Access stage.
Expo SQLite provides the addDatabaseChangeListener
interface, which allows us to get the latest data from the database in real-time, and drizzle provides the useLiveQuery
wrapper. However, currently its hook has an issue with correctly handling the useEffect
dependency array:
In addition, we need to cache the results; otherwise, there will be many unnecessary database queries when switching pages. So, we use swr to wrap our own hook.
import { is, SQL, Subquery } from 'drizzle-orm'
import type { AnySQLiteSelect } from 'drizzle-orm/sqlite-core'
import { getTableConfig, getViewConfig, SQLiteTable, SQLiteView } from 'drizzle-orm/sqlite-core'
import { SQLiteRelationalQuery } from 'drizzle-orm/sqlite-core/query-builders/query'
import { addDatabaseChangeListener } from 'expo-sqlite/next'
import type { Key } from 'swr'
import type { SWRSubscriptionOptions } from 'swr/subscription'
import useSWRSubscription from 'swr/subscription'
export function useQuerySubscription<
T extends
| Pick<AnySQLiteSelect, '_' | 'then'>
| SQLiteRelationalQuery<'sync', unknown>,
SWRSubKey extends Key,
>(
query: T,
key: SWRSubKey,
) {
function subscribe(_key: SWRSubKey, { next }: SWRSubscriptionOptions<Awaited<T>, any>) {
const entity = is(query, SQLiteRelationalQuery)
// @ts-expect-error
? query.table
// @ts-expect-error
: (query as AnySQLiteSelect).config.table
if (is(entity, Subquery) || is(entity, SQL)) {
next(new Error('Selecting from subqueries and SQL are not supported in useQuerySubscription'))
return
}
query.then((data) => { next(undefined, data) })
.catch((error) => { next(error) })
let listener: ReturnType<typeof addDatabaseChangeListener> | undefined
if (is(entity, SQLiteTable) || is(entity, SQLiteView)) {
const config = is(entity, SQLiteTable) ? getTableConfig(entity) : getViewConfig(entity)
let queryTimeout: NodeJS.Timeout | undefined
listener = addDatabaseChangeListener(({ tableName }) => {
if (config.name === tableName) {
if (queryTimeout) {
clearTimeout(queryTimeout)
}
queryTimeout = setTimeout(() => {
query.then((data) => { next(undefined, data) })
.catch((error) => { next(error) })
}, 0)
}
})
}
return () => {
listener?.remove()
}
}
return useSWRSubscription<Awaited<T>, any, SWRSubKey>(
key,
subscribe as any,
)
}
Note the queryTimeout
here!!! Since table changes may be frequent, we need to cancel the previous query, otherwise it will affect the efficiency of the query. Drizzle does not yet support canceling queries using AbortSignal, so we use setTimeout to handle it.
https://github.com/drizzle-team/drizzle-orm/issues/1602
OK, as long as we correctly set the key when requesting data, we can efficiently get the latest data. With pull-to-refresh and scheduled data synchronization, our app can achieve basic Local First.
What's Next?#
This week, I also used a webview to display the details of a feed entry, but it seems a bit slow when loading for the first time. I don't know if it requires special optimization strategies or if webview is just limited. Next week, I will explore rendering HTML to native components.
Let's take a look at what it looks like now!