Skip to content

Frontend Architecture

mhayes853 edited this page Jun 29, 2024 · 5 revisions

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.

Layering

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.

Layers of an Average Feature

  1. The View Layer
    • Consists entirely of React Components.
  2. The View Logic Layer
    • A single or multiple hooks that implement the logic that controls the UI state.
  3. 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.

The View Layer

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.

Relation to 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

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.

Alerts

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.

Multiple Hooks

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.

Projecting a Data Source’s State

Often times, the UI state will be directly tied to a data source that resides in the data layer. In these cases, we like to wrap the data source in a class or interface, and use useSyncExternalStore to hook it into react. 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

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.

Return Values > Throwing Errors

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.

Design Interfaces Tailored to Our Needs

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.

Functional Core, Imperative Shell

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.

Navigation

TODO

Modularization

TODO

Testing

TODO

Clone this wiki locally