-
Notifications
You must be signed in to change notification settings - Fork 0
Frontend Architecture
This document describes the general approach to building features for the frontend of our app. This will be a comprehensive overview of layering, navigation, modularization, and testing.
Much of the processes documented here are flexible, and don’t necessarily need to be followed to the letter. In general, we feel it’s better for formal processes to be discovered over enforcing them without enough context.
Almost all (but not every) code examples are based on real parts of our codebase at one point in time or another, though may be simplified for brevity.
Layering is a common way to abstract functionality, and draw boundaries between specific responsibilities of the app. An average feature has 3 of layers in our app, but more complex or simple features could have more or less layers respectively.
- The View Layer
- Consists entirely of React Components.
- The View Logic Layer
- A single or multiple hooks that implement the logic that controls the UI state.
- The Data Layer
- A collection of classes, functions, etc. that reach into the outside world (eg. Databases, APIs, Hardware, etc.) to provide data or mutate data for the user.
- This layer has the most freedom in terms of code structure, meaning that the programming paradigm used should be best fit for the problem. Sometimes, that’s many classes, most of the time, it’s a single function.
More complex features can introduce layers between the view logic and data layer. A good example of this is joining an event. When the user joins an event, we need to mutate multiple data sources including our backend API, so another layer is added to accommodate the communication with every data source.
Simpler features may omit layers as needed. We want to make it clear that this structure is just what the average feature represents.
The view layer exists as a typical separation of UI and logic. UI often changes at a much faster pace than logic, so enforcing this isolation ensures that our logic is poisoned by pure UI changes.
In general, the view layer and view logic layer live in the same file as they are coupled to an extent. Below shows the general structure of the view layer and how it integrates with the view logic layer.
In general, the view layer should bend to the will of the view logic layer, not the other way around. If the view logic layer defines this hook:
// An Example Hook from the View Logic Layer
export const useFeature = () => {
// Stuff...
return { a, isShowingSheet, buttonTapped: () => setIsShowingSheet(true) }
}Then the UI layer will adapt the return values of the hook like so:
// The View Layer Consuming the View Logic Layer
export type FeatureProps = {
state: ReturnType<typeof useFeature>
style?: StyleProp<ViewStyle>
}
export const FeatureView = ({ state, style }: FeatureProps) => {
const bottomSheetRef = useRef()
useEffect(() => {
if (state.isShowingSheet) {
bottomSheetRef.current.present()
} else {
bottomSheetRef.current.dismiss()
}
}, [state.isShowingSheet])
return (
<View style={style}>
<Button title="Present" onPress={state.buttonTapped} />
<BottomSheet ref={bottomSheetRef}>
{/* Other views */}
</BottomSheet>
</View>
)
}Say instead of a bottom sheet, we decide to switch to a Modal in the future, then we can change the view layer like so without any changes to useFeature.
// A Refactored FeatureView that uses a Modal Instead of a BottomSheet
export const FeatureView = ({ state, style }: FeatureProps) => (
<View style={style}>
<Button title="Present" onPress={state.buttonTapped} />
<Modal visible={state.isShowingSheet}>
{/* Other views */}
</Modal>
</View>
)This separation allows us to adapt the feature to multiple view paradigms if needed with minimal friction.
The view logic layer consists entirely of hooks and pure functions that control all the useState and other variables that the view layer uses. To interact with the data layer (or some other callback), each hook will usually define an “environment type”.
A hook for an example feature may look like this:
// An Example Hook in the View Logic Layer
export type UseAccountInfoSettingsEnvironment = {
signOut: () => Promise<void>
onSignOutSuccess: () => void
}
export const useAccountInfoSettings = ({
signOut,
onSignOutSuccess
}: UseAccountInfoSettingsEnvironment) => {
const signOutMutation = useMutation(signOut, {
onSuccess: onSignOutSuccess,
onError: () => presentAlert("signOutError")
})
return {
shouldDisableActions: signOutMutation.isLoading,
signOutStarted: !signOutMutation.isLoading
? () => {
presentAlert(
"signOutConfirmation",
ALERTS.signOutConfirmation.buttons(() => signOutMutation.mutate())
)
}
: undefined
}
}This hook controls all the state that the view will need, and it even hides implementation details like react-query. The signOut function comes from the data layer, and onSignOutSuccess comes from a parent component, as we’ll need to reroute the user if they sign out successfully.
Another pattern that is visible is to only return functions when the current state makes it valid to call them. Notice how the signOutStarted function is undefined when the user is in the process of signing out. Effectively, the prevents the user from spamming sign-outs from starting since by the rules of typescript, it’s impossible to do so when the sign-out is in progress.
Try to name the method names returned by your hook after direct actions in the UI. Though if possible try to omit the name of the UI element as there may be multiple or different UI elements to invoke the action such as accessibility inputs. This makes it clear to see what methods the view layer should call for button presses, accessibility commands, etc.
Good method names:
signOutStarted
feedbackSubmitted
joinInitiated
dismissed
Ok method names:
joinButtonTapped
leaveButtonTapped
It’s very common to need to present alerts when some action (usually an error) occurs. Since we usually want to verify the specific content of a presented alert when testing, we usually define a constant object containing all the possible alerts for a feature. That may look something like this:
// Where we Place Text for System Alerts
export const BLOCK_LIST_SETTINGS_ALERTS = {
unblockUserConfirmation: {
title: (username: string) => `Unblock ${username}?`,
description: (
user: Pick<BlockListUser, "username" | "handle">
) => `Are you sure you want to unblock ${user.username} (${user.handle})? You will need to wait 48 hours to block them again.
They will be able to view your profile and see your activity, including the events you attend.
You can continue to report them for any inappropriate behavior.`,
buttons: (cancel: () => void, unblock: () => void) => [
{
text: "Cancel",
style: "cancel" as const,
onPress: cancel
},
{
text: "Confirm",
onPress: unblock
}
]
},
unblockUserFailed: {
title: "Uh Oh!",
description: "Unable to unblock user. Please try again later."
}
}This way, we can write tests for a feature hook without worrying about changes to the content of the alert.
Not every feature can be written with a single hook that contains all the state for the view. Often, this is not optimal for performance as it’s more likely that more components will be re-rendered if the state is higher up in the component tree. If needed, feel free to break up a large stateful hook into multiple smaller hooks, and use jotai or another external state-sharing mechanism (like useSyncExternalStore) if necessary.
Often times, the UI state will be directly tied to a data source that resides in the data layer. In these cases, the data layer provides a class or interface to subscribe to the data source. We then use useSyncExternalStore to hook it into react and the view logic layer. Here’s an example:
// Projecting an External Data Source's State to React in the View Logic Layer
import React, {
ReactNode,
createContext,
useCallback,
useContext,
useSyncExternalStore
} from "react"
import { addEventListener } from "@react-native-community/netinfo"
// Data Layer
export type InternetConnectionStatusUnsubscribe = () => void
export interface InternetConnectionStatus {
subscribe(
callback: (isConnected: boolean) => void
): InternetConnectionStatusUnsubscribe
get isConnected(): boolean
}
export class NetInfoInternetConnectionStatus
implements InternetConnectionStatus
{
static readonly shared = new NetInfoInternetConnectionStatus()
private _isConnected = true
subscribe(
callback: (isConnected: boolean) => void
): InternetConnectionStatusUnsubscribe {
return addEventListener((state) => {
this._isConnected = state.isInternetReachable ?? false
callback(this._isConnected)
})
}
get isConnected() {
return this._isConnected
}
}
// View Logic Layer
const InternetConnectionContext = createContext<InternetConnectionStatus>(
NetInfoInternetConnectionStatus.shared
)
export const useIsConnectedToInternet = () => {
const status = useContext(InternetConnectionContext)
return useSyncExternalStore(
useCallback((callback) => status.subscribe(callback), [status]),
() => status.isConnected
)
}In this case, we register event listeners with @react-native-community/netinfo to observe the current internet connectivity status, then we wrap the current status in a class, and use the class in the useIsConnectedToInternet hook. You’ll notice the usage of a context here, and this allows us to create scenarios where the internet may be down or flakey in tests or storybook previews with ease. This step is often crucial for emulating frustrating experiences that the user may have, and it allows us to easily iterate on the UI for those instances.
If the data source you’re projecting doesn’t have a way to alert multiple subscribers of changes, you can use the CallbackCollection utility in the data layer to do so. Here’s an example:
// Handling Multiple Subscribers to a Data Source in the Data Layer
export class PersistentSettingsStore<Settings extends AnySettings>
implements SettingsStore<Settings>
{
private readonly defaultSettings: Settings
private readonly storage: SettingsStorage<Settings>
private currentSettings?: Settings
private subscribers = new CallbackCollection<Settings>()
constructor(defaultSettings: Settings, storage: SettingsStorage<Settings>) {
this.defaultSettings = defaultSettings
this.storage = storage
}
get mostRecentlyPublished() {
return this.currentSettings ?? this.defaultSettings
}
subscribe(callback: (settings: Settings) => void) {
// Load the initial settings if needed...
return this.subscribers.add(callback) // add returns an unsubscribe function
}
update(settings: Partial<Settings>): void {
this.storage.save(settings).catch((err) => {
// Handle error...
})
const newSettings = mergeWithPartial(this.mostRecentlyPublished, settings)
if (!areSettingsEqual(this.mostRecentlyPublished, newSettings)) {
this.currentSettings = newSettings
this.subscribers.send(newSettings)
}
}
}The data layer is responsible for interacting with the outside world, and returning the results of the interactions with the UI. This layer is the most flexible in terms of code style. Feel free to use classes, or just plain functions to get the job done. Everything in this layer is just vanilla ts with no UI code whatsoever.
Many outer world interactions will throw errors if something goes wrong. This is not ideal for when we want to return values to the UI, as it’s hard to get information out of an Error instance. We prefer explicit return types for all expected errors, and throwing errors for unexpected errors. For example, a data layer function to create an account should look like this:
// Modeling a Data Layer Interface
export type CreateAccountResult =
| "success"
| "phone-number-already-exists"
| "email-already-exists"
export const createAccount = async (
name: string,
emailOrPhoneNumber: EmailAddress | USPhoneNumber,
password: Password
): Promise<CreateAccountResult> => {
// ...
}In this case, we expect that the user may enter an email or phone number that already exists, so we model this is a possible outcome of createAccount. We can then consume it in the view logic layer like so:
// View Logic Layer Consuming Data Layer
export type UseSignUpCredentialsFormEnvironment = {
createAccount: (
name: string,
emailOrPhoneNumber: EmailAddress | USPhoneNumber,
password: Password
) => Promise<CreateAccountResult>
onSuccess: (emailOrPhoneNumber: EmailAddress | USPhoneNumber) => void
}
export const useSignUpCredentialsForm = ({
createAccount,
onSuccess
}: UseSignUpCredentialsFormEnvironment) => {
// ...
return {
// ...
submission: useFormSubmission(
async (args) => {
return await createAccount(
args.name,
args.emailOrPhoneNumber,
args.password
)
},
() => {
// ...
},
{
onSuccess: (result, args) => {
if (result === "success") {
onSuccess(args.emailOrPhoneNumber)
} else {
presentAlert(result === "email-already-exists" ? "invalid-email" : "invalid-phone-number")
}
},
onError: () => {
presentAlert("generic-error")
}
}
)
}
}In this case, the view logic layer is forced to handle every possible expected outcome of the account creation up front. If the account creation throws, we can assume that something unexpected happened, because all the expected outcomes were returned upfront and made explicit. Of course, this may clash with react-query at times, but you can always throw an error inside the function of a useQuery if needed.
We’ll often rely on a 3rd party for a particular service in our app, and these 3rd parties will often provide an sdk. Despite this, it’s likely that we won’t need everything that a 3rd party provides, and the components that depend on the functionality provided by the 3rd party for sure will not need everything provided by the 3rd party.
The data layer should only expose types, functions, or classes that represent the data we care about, not what AWS or whoever provides us. Here’s an example:
export type LocationCoordinate2D = {
latitude: number,
longiude: number
}
export type NamedLocation = {
coordinate: LocationCoordinate2D
placemark: Placemark
}
export class LocationsSearchQueryText {
// A fancy type for a query string...
}
export const awsLocationSearch = async (
query: LocationsSearchQueryText,
center: LocationCoordinate2D | undefined,
awsSearch: typeof Geo.searchByText = Geo.searchByText
): Promise<NamedLocation[]> => {
const results = await awsSearch(query.toString(), {
maxResults: 10,
biasPosition: center ? [center.longitude, center.latitude] : undefined
})
return results.ext.compactMap((place: Place) => {
if (!place.geometry) return undefined
return {
coordinate: {
latitude: place.geometry.point[1],
longitude: place.geometry.point[0]
},
placemark: {
name: place.label?.split(", ", 1)[0],
country: place.country,
postalCode: place.postalCode,
street: place.street,
streetNumber: place.addressNumber,
region: place.region,
isoCountryCode: "US",
city: place.municipality
}
}
})
}In this case, we are using AWS to provide location search functionality. The location type we use throughout the app is largely different than what AWS provides. Depending on AWS directly would cause confusion since the rest of the app expects NamedLocation and not AWS’ Place. This also gives us the ability to easily adapt if AWS changed their interface, or if we needed to switch to another provider.
In essence, similar to how the view layer bends to the will of the view logic layer, 3rd parties should bend to the will of our data layer.
This is a term for “separate pure logic from non-pure logic”. In this case, pure logic relates to anything that can produce controllable, deterministic results such as a pure function or class that does not interact with the outside world. The example above in the previous section does not follow this tenet, let’s fix this:
// The Previous Example Using Functional Core, Imperative Shell
export type LocationCoordinate2D = {
latitude: number,
longiude: number
}
export type NamedLocation = {
coordinate: LocationCoordinate2D
placemark: Placemark
}
export class LocationsSearchQueryText {
// A fancy type for a query string...
}
export const namedLocationsFromAWSSearch = (results: Place[]) => {
return results.ext.compactMap((place: Place) => {
if (!place.geometry) return undefined
return {
coordinate: {
latitude: place.geometry.point[1],
longitude: place.geometry.point[0]
},
placemark: {
name: place.label?.split(", ", 1)[0],
country: place.country,
postalCode: place.postalCode,
street: place.street,
streetNumber: place.addressNumber,
region: place.region,
isoCountryCode: "US",
city: place.municipality
}
}
})
}
export const awsSearchOptions = (center: LocationCoordinate2D | undefined) => ({
maxResults: 10,
biasPosition: center ? [center.longitude, center.latitude] : undefined
})
export const awsLocationSearch = async (
query: LocationsSearchQueryText,
center: LocationCoordinate2D | undefined
): Promise<NamedLocation[]> => {
return namedLocationsFromAWSSearch(await Geo.searchByText(query.toString(), awsSearchOptions(center)))
}Before, most of the code in awsLocationSearch was related to converting the AWS location into a NamedLocation. However, this code was intermingled with the IO code that called into the AWS SDK. In the new example, awsLocationSearch is just a single line, and I even removed the search function parameter that was only used as a mock during testing.
With this new design, I can focus the unit testing efforts on converting Place to NamedLocation, and not worrying about if AWS was called correctly. An interaction with AWS behavior is better suited for an integration test in this case, as the awsLocationSearch function is too trivial to unit test thanks to it being a tiny imperative shell. As another side-effect of all of this, namedLocationsFromAWSSearch is a generic function that can be reused thanks to its functional core.
Functional Core, Imperative Shell is an area that we need to improve collectively on the frontend codebase.
TiFAPI is the class we use to interact with the backend. It does not live in the frontend repo, but rather in TiFShared. TiFShared contains documentation for how to add support for a new endpoint. In general, each method of the class will return a discriminated union with the status code and associated data. This way, typescript can infer the content of the response by checking the status code. For example:
// Typesafe API Response by Checking Each Status Code
export const loadEventDetails = async (
eventId: number,
tifAPI: TiFAPI
): Promise<EventDetailsLoadingResult> => {
const resp = await tifAPI.eventDetails(eventId)
if (resp.status === 404) {
return { status: "not-found" }
} else if (resp.status === 403) {
return { status: "blocked", event: resp.data }
} else if (resp.status === 204) {
return { status: "cancelled" }
} else {
return {
status: "success",
event: clientSideEventFromResponse(resp.data)
}
}
}In the else block, since we have checked all other possible status codes, resp.data has the inferred type EventResponse. Notice how we only needed to check a few status codes to get inference in the else block. Similar to the principle of modeling all expected outcomes in the return value, each API method only models expected status codes. If the API responds with an unexpected status code, an error is thrown and a report is sent to Sentry.
Under the hood, the class uses Zod to validate API responses, and it also handles attaching the auth token to each request.
We use sqlite to manage our on-device storage needs. It is likely that we’ll be pushing to add more offline persistence as development continues due to the presence of events being hosted in low/no connectivity areas. Everything from preferences, previously searched locations, and logs are stored in Sqlite.
When using sqlite, use the TiFSQlite class. This class serializes all transactions to ensure that we don’t have to worry about SQLITE_BUSY error codes. This is an approach that is used by the widely popular GRDB library in swift. Here’s an example:
// Using Sqlite
export class SQLiteLocalSettingsStorage implements SettingsStorage<LocalSettings> {
readonly tag = STORAGE_TAG
constructor(private readonly sqlite: TiFSQLite) {}
async save(settings: Partial<LocalSettings>) {
return await this.sqlite.withTransaction(async (db) => {
const newSettings = mergeWithPartial(await this._load(db), settings)
await db.run`
INSERT OR REPLACE INTO LocalSettings (
isHapticFeedbackEnabled,
isHapticAudioEnabled,
hasCompletedOnboarding,
lastEventArrivalsRefreshDate,
userInterfaceStyle,
preferredFontFamily,
preferredBrowserName,
isUsingSafariReaderMode
) VALUES (
${newSettings.isHapticFeedbackEnabled},
${newSettings.isHapticAudioEnabled},
${newSettings.hasCompletedOnboarding},
${newSettings.lastEventArrivalsRefreshDate?.getTime()},
${newSettings.userInterfaceStyle},
${newSettings.preferredFontFamily},
${newSettings.preferredBrowserName},
${newSettings.isUsingSafariReaderMode}
)
`
})
}
}Every query is ran within the withTransaction block, and this ensures that no 2 queries can interleave each other to cause a SQLITE_BUSY error. You’ll also notice the use of template literal syntax on the query string. This allows you to interpolate arguments directly in the SQL string whilst avoiding SQLInjection.
We have 2 instances of TiFSQLite that are used in production. The first instance handles normal user data, and the second instance is entirely for storing on-device logs. You will notice 2 functions in the Migrations namespace for each of these instances.
For testing, we have a global testSQLite instance that combines both the logs and regular database schemas.
Navigation is handled by react-navigation, and we tend to draw some lines with how we use it (it’s quite opinionated…).
First, all navigation lives in the core-root/navigation folder. Individual features do not define which screens they navigate to, as that would entail a lot of coupling. Instead, we separate “View” and “Screen” components. The feature defines a “View” component with props that delegate navigation events, and the “Screen” component lives in core-root/navigation.
// Separation of "View" and "Screen" Components
// In reporting feature module
export type ReportSuccessProps = {
onDoneTapped: () => void
style?: StyleProp<ViewStyle>
}
export const ReportSuccessView = ({
onDoneTapped,
style
}: ReportSuccessProps) => (
<SafeAreaView style={[style, styles.container]}>
{/* ... */}
<View style={styles.doneButtonContainer}>
<PrimaryButton
title="Done"
onPress={onDoneTapped}
style={styles.doneButton}
/>
</View>
</SafeAreaView>
)
// In core-root/navigation
const ReportSuccessScreen = ({ navigation }: ReportSuccessScreenProps) => {
return <ReportSuccessView onDoneTapped={() => navigation.goBack()} />
}The Screen component handles the navigation, but the feature only cares about “Done” being tapped. Often we’ll reuse the same View component but with different route props, or navigation logic in other features. So in this sense, 1 View component can have multiple Screen components. This separation also allows us to put the view component in an isolated storybook story where we don’t really care about navigation.
Second, we create reusable router functions for a set of screens that model a particular UI flow. This does add an annoying amount of boilerplate, but it allows us to spin up and isolated navigation flow for testing and/or storybook previewing. For instance:
// A Way to Reuse Screens Across Different Stack Navigators.
export type ReportingScreensParamsList = {
reportSuccess: { contentType: ReportableContentType }
reportContent: { contentId: string; contentType: ReportableContentType }
}
type ReportSuccessScreenProps = StackScreenProps<
ReportingScreensParamsList,
"reportSuccess"
>
type ReportingScreenStackProps = StackScreenProps<
ReportingScreensParamsList,
"reportContent"
>
export const createContentReportingScreens = <
T extends ReportingScreensParamsList
>(
stack: StackNavigatorType<T>,
onReported: (
contentId: string,
contentType: ReportableContentType,
reason: ReportingReason
) => Promise<void>
) => {
return (
<>
<stack.Screen
name="reportSuccess"
component={ReportSuccessScreen}
options={({ route }: ReportSuccessScreenProps) => ({
headerLeft: () => <ChevronBackButton />,
title: `Report ${capitalizeFirstLetter(route.params.contentType)}`
})}
/>
<stack.Screen
name="reportContent"
options={({ route }: ReportingScreenStackProps) => ({
headerLeft: () => <ChevronBackButton />,
title: `Report ${capitalizeFirstLetter(route.params.contentType)}`
})}
>
{(props: ReportingScreenStackProps) => (
<ReportingScreen {...props} onReported={onReported} />
)}
</stack.Screen>
</>
)
}
type ReportContentScreenProps = {
onReported: (
contentId: string,
contentType: ReportableContentType,
reason: ReportingReason
) => Promise<void>
} & StackScreenProps<ReportingScreensParamsList, "reportContent">
// NB: We need to memoize all screens that take additional props.
const ReportingScreen = memo(function Screen ({
route,
navigation,
onReported
}: ReportContentScreenProps) {
const { contentId, contentType } = route.params
return (
<ReportFormView
contentType={contentType}
onSubmitted={async (reason) => {
await onReported(contentId, contentType, reason)
navigation.replace("reportSuccess", { contentType })
}}
/>
)
})
const ReportSuccessScreen = ({ navigation }: ReportSuccessScreenProps) => {
return <ReportSuccessView onDoneTapped={() => navigation.goBack()} />
}With this, any StackNavigator can add the reporting flow to itself. Additionally, we can also spin up an isolated preview of the reporting flow for testing or previewing purposes. Even though the boilerplate is annoying, we feel the power that this gives us is enough to take the hit.
Modularization relates to how files and folders are structured. In general, we like to keep feature related things together, and not split modules by technical layers. That being said, often times separate features will depend on each other, so there needs to be some formal boundaries.
An average feature can include all its layers inside a single file. For instance:
// JoinEvent.ts
export const JOIN_EVENT_ERROR_ALERTS = {
// ...
}
export const loadJoinEventPermissions = async () => [
// ...
]
export type JoinEventPermissionKind = "notifications" | "backgroundLocation"
export type JoinEventPermission = {
kind: JoinEventPermissionKind
canRequestPermission: boolean
requestPermission: () => Promise<void>
}
export type JoinEventResult =
| "success"
| "event-has-ended"
| "event-was-cancelled"
| "user-is-blocked"
export type JoinEventRequest = Pick<ClientSideEvent, "id"> & {
location: Omit<EventLocation, "timezoneIdentifier">
hasArrived: boolean
}
export const joinEvent = async (
request: JoinEventRequest,
tifAPI: TiFAPI,
recentLocationsStorage: RecentLocationsStorage
): Promise<JoinEventResult> => {
// ...
}
export type UseJoinEventEnvironment = {
monitor: EventRegionMonitor
joinEvent: (request: JoinEventRequest) => Promise<JoinEventResult>
loadPermissions: () => Promise<JoinEventPermission[]>
}
export type UseJoinEventPermissionStage = {
permissionKind: JoinEventPermissionKind
requestButtonTapped: () => void
dismissButtonTapped: () => void
}
export type UseJoinEventStage =
| { stage: "idle"; joinButtonTapped: () => void }
| { stage: "loading" | "success" }
| ({ stage: "permission" } & UseJoinEventPermissionStage)
export const useJoinEventStages = (
event: Omit<JoinEventRequest, "hasArrived">,
env: UseJoinEventEnvironment
): UseJoinEventStage => {
// ...
}
const useCurrentJoinEventPermission = (
loadPermissions: () => Promise<JoinEventPermission[]>
) => {
// ...
}
export type JoinEventStagesProps = {
stage: UseJoinEventStage
style?: StyleProp<ViewStyle>
}
export const JoinEventStagesView = ({ stage, style }: JoinEventStagesProps) => (
// ...
)
export type JoinEventPermissionBannerModalProps = {
currentStage: UseJoinEventStage
}
const JoinEventPermissionBannerModal = ({
currentStage
}: JoinEventPermissionBannerModalProps) => {
// ...
}
export type JoinEventPermissionBannerProps = {
permissionKind: JoinEventPermissionKind
requestButtonTapped?: () => void
dismissButtonTapped?: () => void
style?: StyleProp<ViewStyle>
}
const JOIN_EVENT_PERMISSION_BANNER_CONTENTS = {
// ...
}
const JoinEventPermissionBanner = ({
permissionKind,
requestButtonTapped,
dismissButtonTapped,
style
}: JoinEventPermissionBannerProps) => {
// ...
}This file contains the data layer, view logic layer, and view layer in a single file. In particular, this file relates to the join event flow, and a lot of things must happen when an event is joined. For instance, we may need to ask the user to turn on background location and push notification permissions (in sequence). We also want to update the list of recently interacted locations, so that the location of the event has a higher priority when searching for a location in the app.
This will often produce larger files with quite a few hundred lines (mostly JSX code), but we find it often to be the simplest way to look through a feature as everything is in the same place. Of course, if functions need to be shared across multiple features, then they can have their own separate file.
There are 3 kinds of folders, and they all have quirks about that they can import.
-
core-root- This folder is responsible for wiring all the other feature modules together, setting up all the stack/tab/react navigators. It effectively depends on every other module in the application since it also contains the root view.
-
xxx-boundary- Folders with the
boundarysuffix are considered end to end features, and they represent logical boundaries of the app. The latter statement means that noboundarymodule is allowed to import anotherboundarymodule. This restriction ensures that circular imports are avoided, and that end to end features are mostly decoupled from each other.
- Folders with the
- Everything Else == Shared Folders
- Every other folder in the application is treated as if it was a shared library that can be used by other shared libraries or by end to end features. If 2
boundarymodules need to share code, then a shared folder can facilitate that sharing without having the 2 boundaries import each other.
- Every other folder in the application is treated as if it was a shared library that can be used by other shared libraries or by end to end features. If 2
Software generally doesn’t work unless it is tested, so we make sure to test. Unfortunately, react native makes it hard to write integration tests with native code on a real device without resorting to slow UI testing tools like detox. Therefore, it’s more beneficial to take the unit tests whilst mocking the native code approach. When possible, we do try to integrate multiple components together in a typical jest environment (if it makes sense to do so), but this is not always possible.
We also don’t think 100% code coverage is a necessary goal, and coverage that matters is more important and should come as a side-effect of decent testing practices.
Try to cover behavior when testing more than implementation details. For example, the desired behavior of a joining an event is to mark everywhere in the UI that the event has been joined. In this case, assert that the UI has changed to reflect the join, not that you updated the proper query key in the react-query cache. Of course, sometimes the best you can do is assert that some function was called with the correct arguments, but in many cases you can do better.
We like to colocate our test files with the production code files, so you don’t have to jump around to a random tests folder. Each test file contains a top level describe block, with a naming convention of "FilePurpose tests". If the file contains multiple feature layers or separate pieces of functionality, we’ll often use nested describe blocks to organize tests for each layer or piece of functionality.
// An Example Test File for a Feature
describe("ExploreEvents tests", () => {
describe("EventsByRegion tests", () => {
test("loading events successfully for a region", async () => {
// ...
})
// ...
})
describe("UseExploreEvents tests", () => {
test("exploring events successfully at user location", async () => {
// ...
})
test("retrying after unsuccessfully exploring events", async () => {
// ...
})
it("should be in a no-results state when no events for region", async () => {
// ...
})
// ...
})
// ...
})In this case the data and view logic layers have a separate describe block.
Since we are building a UI application, about ~60-70% the codebase of the codebase consists of the view and view logic layers. These layers both depend on react, and we use the react testing library to test these layers.
However, we place more emphasis on testing the view logic layer (hooks) over testing the view layer (components) directly with RTL, this is for a few reasons:
- (This is the biggest reason) Views that contain native code tend to not be treated well by RTL, and it can be painfully annoying and time consuming to have to write a mock for every native component that a view uses.
- Most RTL tests are generally not testing the JSX output, but rather the logic invoked by actions in the UI such as button presses. We can simply delegate the entirety of the button press logic to a hook.
- Since the hook handles all of the logic, the view tends to be a relatively simple function that changes frequently in unrelated ways to the logic. In general, we feel like it would be more beneficial to use testing tools like detox to test the view layer, as we don’t need to mock native components, and those tests truly interact with the application from the user perspective.
We still think it’s valuable to use RTL to test components directly in many cases, but we think that those kinds of tests are best left to testing navigation logic, and highly visible calculations (eg. A rich text renderer). Nevertheless, we will cover testing both views and hooks directly.
RTL has built in support for testing hooks using the renderHook function. We like to create a custom function that wraps renderHook in order to reduce duplicate setup code. Since most feature hooks expose a UseXXXHookEnvironemnt type that contains functions that interact with the data layer, you can choose to either pass a mock or a real instance of the data layer code to the hook for testing. The choice to use either a mock or real instance depends on the context, but given the limited environment there will almost always be a mock somewhere. Additionally, hooks will often call out to Alert.alert to present system alerts, you can use the captureAlerts utility to inspect the content of these alerts (and even tap buttons) for the sake of testing.
Putting everything together:
// Testing a Hook
describe("HelpAndSupportSettings tests", () => {
describe("UseHelpAndSupportSettings tests", () => {
const TEST_COMPILE_LOGS_URI = "test/logs.zip"
// These mocks are necessary because the actual implementations call
// native code that is not available in a jest environment.
const { alertPresentationSpy, tapAlertButton } = captureAlerts()
const isShowingContactSection = jest.fn()
const compileLogs = jest.fn()
const composeEmail = jest.fn()
beforeEach(() => jest.resetAllMocks())
// ...
test("Successful submit feedback flow", async () => {
const result = await renderSuccessfulEmailCompositionFlow()
act(() => result.current.feedbackSubmitted())
await waitFor(async () =>
expect(alertPresentationSpy).toHaveBeenCalledWith(
HELP_AND_SUPPORT_EMAIL_SUCCESS_ALERTS.submitFeedbackSuccess.title,
HELP_AND_SUPPORT_EMAIL_SUCCESS_ALERTS.submitFeedbackSuccess
.description
)
)
expect(composeEmail).toHaveBeenCalledWith(
HELP_AND_SUPPORT_EMAILS.feedbackSubmitted
)
})
test("Unsuccessful submit feedback flow", async () => {
const result = await renderUnsuccessfulEmailCompositionFlow()
act(() => result.current.feedbackSubmitted())
await waitFor(async () =>
expect(alertPresentationSpy).toHaveBeenCalledWith(
HELP_AND_SUPPORT_EMAIL_ERROR_ALERTS.submitFeedbackError.title,
HELP_AND_SUPPORT_EMAIL_ERROR_ALERTS.submitFeedbackError.description
)
)
})
test("Successful report bug flow: logs failure, switch to no logs", async () => {
compileLogs.mockRejectedValueOnce(new Error("Something went wrong"))
const result = await renderSuccessfulEmailCompositionFlow()
act(() => result.current.bugReported())
await reportWithLogs()
await waitFor(async () =>
expect(alertPresentationSpy).toHaveBeenCalledWith(
HELP_AND_SUPPORT_ALERTS.compileLogError.title,
HELP_AND_SUPPORT_ALERTS.compileLogError.description,
expect.any(Array)
)
)
await reportWithoutLogsAfterFailure()
await waitFor(async () =>
expect(alertPresentationSpy).toHaveBeenCalledWith(
HELP_AND_SUPPORT_EMAIL_SUCCESS_ALERTS.reportBugSuccess.title,
HELP_AND_SUPPORT_EMAIL_SUCCESS_ALERTS.reportBugSuccess.description
)
)
expect(composeEmail).toHaveBeenCalledWith(
HELP_AND_SUPPORT_EMAILS.bugReported(undefined)
)
})
// ...
const renderSuccessfulEmailCompositionFlow = async () => {
composeEmail.mockResolvedValue("success")
const { result } = renderUseHelpAndSupportSettings()
await waitFor(() =>
expect(result.current.isShowingContactSection).toEqual(true)
)
return result
}
const renderUnsuccessfulEmailCompositionFlow = async () => {
composeEmail.mockRejectedValue(new Error("Bad error"))
const { result } = renderUseHelpAndSupportSettings()
await waitFor(() =>
expect(result.current.isShowingContactSection).toEqual(true)
)
return result
}
const renderUseHelpAndSupportSettings = () => {
return renderHook(
() =>
useHelpAndSupportSettings({
isShowingContactSection,
compileLogs,
composeEmail
}),
{
wrapper: ({ children }: any) => (
<TestQueryClientProvider>{children}</TestQueryClientProvider>
)
}
)
}
const reportWithoutLogs = async () => {
await tapAlertButton("No")
}
const reportWithoutLogsAfterFailure = async () => {
await tapAlertButton("OK")
}
const reportWithLogs = async () => {
await tapAlertButton("Yes")
}
})
})You can see multiple of the patterns mentioned in earlier sections taking effect here, such as creating constant objects to represent alerts. We also like to create test helper functions to keep the test body language akin to a description of the particular UI flow under test.
Most view tests handle rendering of highly calculative views, or navigation flows. In general, UI changes a lot, so we like to create helpers for interacting with the view so that the test bodies represent a description of the UI flow. These helpers do not mention any UI elements in their names, as the UI element to invoke a specific action may change. Also users do not generally think of UI elements as the main value they get from apps.
Putting this together generally achieves the following:
// Testing Multiple Views in a Navigation Flow
describe("SignInNavigation tests", () => {
const TEST_PASSWORD = "12345678"
const cognito = {
signIn: jest.fn(),
resendSignUp: jest.fn(),
confirmSignIn: jest.fn()
}
const authenticator = new CognitoSignInAuthenticator(cognito)
test("sign in with correct credentials", async () => {
renderSignInScreens()
beginSignInTest()
enterPhoneNumberText(USPhoneNumber.mock.toString())
enterPasswordText(TEST_PASSWORD)
cognito.signIn.mockResolvedValueOnce({})
submitSignInCredentials()
await waitFor(() => expect(isAtEnd()).toEqual(true))
})
it("should switch over to the sign up flow when sign up verification required", async () => {
renderSignInScreens()
beginSignInTest()
enterPhoneNumberText(USPhoneNumber.mock.toString())
enterPasswordText(TEST_PASSWORD)
cognito.signIn.mockRejectedValueOnce(
new TestCognitoError("UserNotConfirmedException")
)
submitSignInCredentials()
await waitFor(() => {
expect(signUpVerifyCodeForm(USPhoneNumber.mock)).toBeDisplayed()
})
})
// ...
const tapForgotPassword = () => {
fireEvent.press(screen.getByText("Forgot your password?"))
}
const enterPhoneNumberText = (text: string) => {
fireEvent.changeText(
screen.getByPlaceholderText("Phone number or Email"),
text
)
}
const enterPasswordText = (text: string) => {
fireEvent.changeText(screen.getByPlaceholderText("Password"), text)
}
const submitSignInCredentials = () => {
fireEvent.press(screen.getByText("I'm back!"))
}
const submitVerificationCode = () => {
fireEvent.press(screen.getByLabelText("Verify me!"))
}
// ..
const renderSignInScreens = () => {
const Stack = createStackNavigator<TestParamsList>()
const ModalStack = createStackNavigator<SignInParamsList>()
const signInScreens = createSignInScreens(ModalStack, authenticator)
return render(
<TestQueryClientProvider>
<NavigationContainer>
<Stack.Navigator
initialRouteName="test"
screenOptions={{ animationEnabled: false }}
>
<Stack.Screen name="test" component={TestScreen} />
<Stack.Screen name="signIn">
{() => (
<ModalStack.Navigator
initialRouteName="signInForm"
screenOptions={{ animationEnabled: false }}
>
{signInScreens}
{/* ... */}
</ModalStack.Navigator>
)}
</Stack.Screen>
</Stack.Navigator>
</NavigationContainer>
</TestQueryClientProvider>
)
}
const IS_AT_END_TEST_ID = "test-sign-in-flow-end"
const beginSignInTest = () => {
fireEvent.press(screen.getByText("Begin Sign-in Test"))
}
const isAtEnd = () => !!screen.queryByTestId(IS_AT_END_TEST_ID)
const TestScreen = ({
navigation
}: StackScreenProps<TestParamsList, "test">) => {
// ...
}
})This example also shows how it’s possible to reuse screens across stack navigators, and how to spin up an isolated navigation flow in renderSignInScreens. The syntax is a little weird, but that is due to react-navigation wanting only Stack.Screen components to be children of Stack.Navigator components.
Outside of the react code, the testing style you use of course depends on the problem. Often times, we like to follow the functional core, imperative shell style which advocates for separating pure code (pure functions, immutable classes, etc.) from non-pure code (IO, Network Requests, etc.). This allows us to test the data transformations irrespective of the how the data is fetched or retrieved from a specific data source which is especially useful since we often cannot run the live data source implementations in a jest environment. See the data layer section for more info about this pattern.
When possible, try to follow functional core, imperative shell. However, you will still want to test how we interact with specific data sources, even if you are forced to mock.
When testing code that depends on the TiFAPI class, we like to use msw to mock responses from the API. Here’s an example of how to use msw in relation to testing code that depends on TiFAPI:
// Testing Code that Depends on TiFAPI
import { mswServer } from "@test-helpers/msw"
// More Imports...
describe("UserSettings tests", () => {
// ...
describe("UserSettingsSynchronizingStore tests", () => {
const TEST_DEBOUNCE_MILLIS = 5000
const storage = new SQLiteUserSettingsStorage(testSQLite)
let store: UserSettingsSynchronizingStore
beforeEach(() => {
resetSavedAPISettings()
store = userSettingsStore(
storage,
// Be sure to use the testAuthenticatedInstance for most tests.
TiFAPI.testAuthenticatedInstance,
createTestQueryClient(),
TEST_DEBOUNCE_MILLIS,
0
)
})
// Test cases ...
let savedAPISettings = DEFAULT_USER_SETTINGS as UserSettings
const USER_SETTINGS_ENDPOINT_PATH = TiFAPI.testPath("/user/self/settings")
const setGetSettingsResponse = (settings: UserSettingsResponse) => {
mswServer.use(
http.get(USER_SETTINGS_ENDPOINT_PATH, async () => {
return HttpResponse.json(settings)
})
)
}
const setupGetSettingsErrorResponse = () => {
mswServer.use(
http.get(USER_SETTINGS_ENDPOINT_PATH, async () => {
return HttpResponse.error()
})
)
}
const setupUpdateSettingsEndpoint = () => {
mswServer.use(
http.patch(USER_SETTINGS_ENDPOINT_PATH, async ({ request }) => {
const body = (await request.json()) as UpdateUserSettingsRequest
savedAPISettings = mergeWithPartial(savedAPISettings, body)
return HttpResponse.json(savedAPISettings, { status: 200 })
})
)
}
const setupUpdateSettingsFailingEndpoint = () => {
mswServer.use(
http.patch(USER_SETTINGS_ENDPOINT_PATH, async () => {
return HttpResponse.error()
})
)
}
const resetSavedAPISettings = () => {
savedAPISettings = DEFAULT_USER_SETTINGS
}
})
})With enough hacking, we made it possible to get expo-sqlite to run in tests by using better-sqlite3 to implement its required native interface. However, you will be using the TiFAPI class, which wraps expo-sqlite. Make sure to always use the testSQLite global variable when passing a TiFSQLite to code under test. testSQLite is an in-memory instance of TiFSQLite, and it has a resetTestSQLiteBeforeEach helper function that will reset the state of the sqlite database before each test.
Putting it all together:
// Testing a Class that Depends on TiFSQLite
describe("LocalSettings tests", () => {
describe("SQLiteLocalSettingsStorage tests", () => {
resetTestSQLiteBeforeEach()
const storage = new SQLiteLocalSettingsStorage(testSQLite)
it("should return the default settings when calling load with no changes", async () => {
const settings = await storage.load()
expect(settings).toEqual(DEFAULT_LOCAL_SETTINGS)
})
it("should return the updated settings when calling load with changes", async () => {
await storage.save({ hasCompletedOnboarding: true })
const settings = await storage.load()
expect(settings).toEqual({
...DEFAULT_LOCAL_SETTINGS,
hasCompletedOnboarding: true
})
})
it("should merge changes when saving", async () => {
await storage.save({ hasCompletedOnboarding: true })
await storage.save({ isHapticAudioEnabled: false })
const settings = await storage.load()
expect(settings).toEqual({
...DEFAULT_LOCAL_SETTINGS,
hasCompletedOnboarding: true,
isHapticAudioEnabled: false
})
})
})
})TODO - Write this when the roswaal is fully finished.
One of our most important processes is isolating pieces of UI in a sandbox so we can design, prototype, and experience the UI that we create. We use storybook for this purpose, and we like to spin up entire screens and navigation flows in isolation as separate storybook stories. The code in each storybook story is not the cleanest, as it’s meant to be a playground that allows us to mess around with different dependencies of the UI under preview.
You can run the storybook code using the npm run sb_start script. This script will launch the development server with an environment variable that indicates that the storybook app target should be ran, and not the main app target. Both the main app and storybook app share the same AppEntry.js entrypoint file, so you can use the same EAS development build to run either app.
As mentioned earlier, we like to spin up entire screens or complex UI flows in each storybook story. All stories are isolated from each other, meaning that no story imports another story. Generally, this means that you will need to spin up all context providers, react-navigation navigators, database connections, etc. from scratch. We feel like this process is fine, since each story is a playground, and you may want to mess with different pieces of the configuration. Here’s an example of what a story looks like:
import { StoryMeta } from ".storybook/HelperTypes"
import { BASE_HEADER_SCREEN_OPTIONS } from "@components/Navigation"
import { TiFQueryClientProvider } from "@lib/ReactQuery"
import { delayData } from "@lib/utils/DelayData"
import {
LocationSearchPicker,
useLocationSearchPicker,
LocationSearchBar
} from "@location-search-boundary"
import { mockLocationSearchResult } from "@location-search-boundary/MockData"
import {
NavigationContainer,
NavigationProp,
ParamListBase,
useNavigation
} from "@react-navigation/native"
import { createStackNavigator } from "@react-navigation/stack"
import { ComponentMeta, ComponentStory } from "@storybook/react-native"
import { repeatElements } from "TiFShared/lib/Array"
import React from "react"
import { Button, View } from "react-native"
import { useSafeAreaInsets } from "react-native-safe-area-context"
const LocationSearchMeta: StoryMeta = {
title: "Location Search Screen"
}
export default LocationSearchMeta
type LocationSearchStory = ComponentStory<typeof View>
const Stack = createStackNavigator()
export const Basic: LocationSearchStory = () => (
<TiFQueryClientProvider>
<NavigationContainer>
<Stack.Navigator screenOptions={{ ...BASE_HEADER_SCREEN_OPTIONS }}>
<Stack.Screen name="settings" component={TestScreen} />
<Stack.Screen
name="search"
options={{
header: () => <LocationSearchHeader />,
headerMode: "screen"
}}
component={LocationSearchScreen}
/>
</Stack.Navigator>
</NavigationContainer>
</TiFQueryClientProvider>
)
const LocationSearchHeader = () => {
const insets = useSafeAreaInsets()
const navigation: NavigationProp<ParamListBase> = useNavigation()
return (
<LocationSearchBar
placeholder="Search for locations..."
onBackTapped={navigation.goBack}
style={{ marginTop: insets.top, marginHorizontal: 16 }}
/>
)
}
const LocationSearchScreen = () => {
const picker = useLocationSearchPicker({
loadSearchResults: async () => {
return await delayData(
repeatElements(15, () => mockLocationSearchResult()),
3000
)
}
})
return (
<LocationSearchPicker
{...picker}
savePickedLocation={(location) => console.log("Saved", location)}
onUserLocationSelected={console.log}
onLocationSelected={console.log}
contentContainerStyle={{ paddingVertical: 16 }}
/>
)
}
const TestScreen = () => {
const navigation: NavigationProp<ParamListBase> = useNavigation()
return (
<Button
title="Go to Location Search"
onPress={() => navigation.navigate("search")}
/>
)
}Here we spin up the location search screen in an isolated navigation flow so that we can see the custom back button behavior. We also can easily drop mock data or even pretend that an error occurred in the view by leveraging the UseLocationSearchPickerEnvironment type that is a required parameter of useLocationSearchPicker. Doing this allows us to emulate all sorts of weird scenarios that may happen in production, and allows us to iterate on the UI for those scenarios.