How BlueSky Structure Their App and Manage State

June 05, 2023 10 minute read

Browsing through libraries is an enriching experience, but they’re too focused and lack the architectural depth that building an entire application demands. So I was ecstatic when I heard that BlueSky had open-sourced their web and mobile app.

Data fetching, routing, and reusing components are nothing new. But managing them in a frequently changing application is still an unsolved problem that trips up many teams.

I opened the repository, pinned the tab, and left it between the other resources I plan to read one day. But the circumstances forced my hand. I was working on some updates for Tao of React and a greenfield project, so I desperately needed inspiration.

Note: some details will be stripped from the code examples for brevity and simplicity.

Expo

Building an application once and deploying it everywhere has long been the dream of the software world. BlueSky is using Expo - a platform for building “universal” applications. In other words, a tool that allows you to deploy the same codebase to the web and mobile devices.

I’ve been away from mobile development for years, and I’m happy to see that there’s finally a mature technology that enables us to do this.

Expo is a toolchain created around React Native, and it’s meant to make mobile development easier. But because it integrates with react-native-web, we can also deploy the application to the web.

Browsing through BlueSky’s codebase, you will notice some components have a .web.tsx suffix, making them browser-specific.

It seems to be an excellent platform that opens up more capabilities for mobile development. At the same time, it’s not an intrusive tool that will affect how you build your web application, so I recommend using it if you’re making something that has to run on multiple environments.

High-Level Structure

The file structure follows accepted conventions and doesn’t deviate from the React applications you’re used to seeing at work. On the root level, we have scripts, e2e tests, assets, docs, and a src directory that is most interesting.

Inside it, we see a simple and intuitive structure.

├── src
|   ├── lib
|   ├── locale
|   ├── platform
|   ├── state
|   ├── view
|   ├── App.native.tsx
|   ├── App.web.tsx
|   ├── Navigation.tsx
|   ├── routes.tsx

Before I start browsing through the actual code of an unfamiliar project, I go through the files to get an idea of how things are layouted. Here, view and lib immediately grab my attention. Inside view, we find the pages and components split into three groups.

├── src
|   ├── ...
|   ├── view
|   |   ├── com
|   |   ├── screens
|   |   ├── shell
|   |   ├── index.ts

The screens folder seems to hold the pages, each representing a different route. The com one is a shorthand for components, and shell is where the application layout is kept. Out of these three, the application shell is the most interesting because building a robust skeleton for the UI is challenging.

We’ll dive deeper into it below.

Inside com, we see a list of components separated by kind. For example, a modals directory inside of com holds all the various modals (obviously).

It makes it clear what purpose a component serves, but not where it’s used, though.

The lib folder, on the other hand, seems to hold everything from hooks to request handlers, types, and utility functions.

├── src
|   ├── ...
|   ├── lib
|   |   ├── api
|   |   ├── async
|   |   ├── hooks
|   |   ├── labeling
|   |   ├── link-meta
|   |   ├── media
|   |   ├── routes
|   |   ├── strings
|   |   ├── ThemeContext.tsx
|   |   ├── analytics.tsx
|   |   ├── ...

Anything that isn’t a component or a part of the state logic has ended up here. If you’ve read Tao of React, or any of my other posts, you know that I’m a big advocate for co-location - putting the logic close to where it’s used, because it communicates where a function or a component is called.

However, in BlueSky’s case, this is not that big of a problem.

Later we’ll see that their domain logic is implemented in the classes responsible for state management. So the lib directory is something of a holder for utilities with various purposes.

Application Shell

Most applications I’ve worked on call this component a “layout”. It’s the skeleton that wraps your project, sets up context providers, navigation, and any components that need to be seen and used across all pages.

In BlueSky’s case, they call it a “shell” which sounds slightly cooler.

It’s made of two components - Shell and ShellInner, with the inner one being more interesting to us.

const ShellInner = observer(() => {
  const store = useStores()
  const { isDesktop } = useWebMediaQueries()
  const navigator = useNavigation()

  useEffect(() => {
    navigator.addListener('state', () => {
      store.shell.closeAnyActiveElement()
    })
  }, [navigator, store.shell])

  return (
    <>
      <View style={s.hContentRegion}>
        <ErrorBoundary>
          <FlatNavigator />
        </ErrorBoundary>
      </View>
      {isDesktop && store.session.hasSession && (
        <>
          <DesktopLeftNav />
          <DesktopRightNav />
        </>
      )}
      <Composer
        active={store.shell.isComposerActive}
        onClose={() => store.shell.closeComposer()}
        winHeight={0}
        replyTo={store.shell.composerOpts?.replyTo}
        quote={store.shell.composerOpts?.quote}
        onPost={store.shell.composerOpts?.onPost}
      />
      {!isDesktop && <BottomBarWeb />}
      <ModalsContainer />
      <Lightbox />
      {!isDesktop && store.shell.isDrawerOpen && (
        <TouchableOpacity
          onPress={() => store.shell.closeDrawer()}
          style={styles.drawerMask}
          accessibilityLabel="Close navigation footer"
          accessibilityHint="Closes bottom navigation bar"
        >
          <View style={styles.drawerContainer}>
            <DrawerContent />
          </View>
        </TouchableOpacity>
      )}
    </>
  )
})

The shell component sets up error boundaries, navigations, modal containers, a drawer, and the post composer component that needs to be accessible everywhere. Nothing we haven’t seen before.

But something worth highlighting is that the shell observes and reacts based on the global application state.

On the first line, we see that the component is wrapped in an observer function which allows it to access the global state. Then, a very interesting detail is that in the useEffect call, the shell delegates the state changes to another function instead of making them imperatively.

You will notice the conditional checks in the JSX the shell reacts to, but it doesn’t modify the state values itself. So let’s jump to the state management bit and figure out what’s happening there.

State Management

I’m ashamed to say this is the first Mobx codebase I’m exploring. Throughout the years, I worked on Redux-dominated projects, then jumped on contexts and atom-based state management solutions. Mobx was always under my radar, but I was pleasantly surprised with how it works.

You can make a piece of observable state from any object. I’m sure there are some idiomatic practices in the Mobx community, but observing any JavaScript object and even a class is very refreshing.

And this gives you additional flexibility when defining your business logic.

BlueSky takes an OOP approach and implements its state in model classes. Even though I’m not a proponent of object-oriented programming, this is much better than coupling the logic in the React components.

I believe we’ll see more and more patterns like this as front-end development matures.

export class ShellUiModel {
  // ...
  isComposerActive = false
  composerOpts: ComposerOpts | undefined

  constructor(public rootStore: RootStoreModel) {
    makeAutoObservable(this, {
      serialize: false,
      rootStore: false,
      hydrate: false,
    })
  }

  /**
   * returns true if something was closed
   * (used by the android hardware back btn)
   */
  closeAnyActiveElement(): boolean {
    if (this.isLightboxActive) {
      this.closeLightbox()
      return true
    }
    if (this.isModalActive) {
      this.closeModal()
      return true
    }
    if (this.isComposerActive) {
      this.closeComposer()
      return true
    }
    if (this.isDrawerOpen) {
      this.closeDrawer()
      return true
    }
    return false
  }

  // ...

  openComposer(opts: ComposerOpts) {
    this.rootStore.emitNavigation()
    this.isComposerActive = true
    this.composerOpts = opts
  }

  closeComposer() {
    this.isComposerActive = false
    this.composerOpts = undefined
  }
}

The makeAutoObservable call receives a this reference and creates a new observable piece of state out of the passed object - in this case, the class.

This class is responsible for the state handled by the ShellInner component we looked at above. I’ve omitted a big part of it so we can focus on the specifics. Take a look at how the Composer component is used inside the shell. You will notice that the shell itself is not implementing any functionality related to opening or closing.

It’s only propagating the value from this model.

In addition to that, we get the opportunity to call methods from the observed objects. So the shell component doesn’t have to define the state-related logic. It can just call closeComposer or closeAnyActiveElement as a result of an event.

The same pattern is implemented in the model representing the user.

export class MeModel {
  // ...

  get invitesAvailable() {
    return this.invites.filter(isInviteAvailable).length
  }

  constructor(public rootStore: RootStoreModel) {
    makeAutoObservable(
      this,
      { rootStore: false, serialize: false, hydrate: false },
      { autoBind: true }
    )
    this.mainFeed = new PostsFeedModel(this.rootStore, 'home', {
      algorithm: 'reverse-chronological',
    })
    this.notifications = new NotificationsFeedModel(this.rootStore)
    this.follows = new MyFollowsCache(this.rootStore)
    this.savedFeeds = new SavedFeedsModel(this.rootStore)
  }

  async fetchProfile() {}

  async updateIfNeeded() {}

  async fetchInviteCodes() {
    if (this.rootStore.session) {
      try {
        const res =
          await this.rootStore.agent.com.atproto.server.getAccountInviteCodes(
            {}
          )
        runInAction(() => {
          this.invites = res.data.codes
          this.invites.sort((a, b) => {
            if (!isInviteAvailable(a)) {
              return 1
            }
            if (!isInviteAvailable(b)) {
              return -1
            }
            return 0
          })
        })
      } catch (e) {
        this.rootStore.log.error(
          'Failed to fetch user invite codes',
          e
        )
      }
      await this.rootStore.invitedUsers.fetch(this.invites)
    }
  }

  async fetchAppPasswords() {}

  async createAppPassword(name: string) {}

  async deleteAppPassword(name: string) {}
}

This class looks no different than any other class used outside of a web-specific context. I’ve only left the fetchInviteCodes method for brevity, but it showcases how data is loaded from an agent object which makes an API call, then it is assigned to the invites property and sorted.

Any React component observing this property will be notified of the change, and it can list the invite codes.

This is the kind of logic we’re used to seeing implemented inside the components. But by abstracting it, we don’t have to manage all our data through React state and effects. Mutating data like this is also a good approach if we can notify React that a piece of data has changed.

In this case, Mobx takes care of that.

Component Design Decisions

The components are readable and stay consistent with standard React best practices. However, three things stood out to me.

First, I noticed that they wrap everything in a useCallback, avoiding inlined anonymous functions.

const onLayout = React.useCallback(() => {
  const index = posts.findIndex((post) => post._isHighlightedPost)
  if (index !== -1) {
    ref.current?.scrollToIndex({
      index,
      animated: false,
      viewOffset: 40,
    })
  }
}, [posts, ref])

I think this is an excellent practice, and it’s something I’m starting to adopt myself. On one side, this reduces the chance of a performance problem in the future, but it also gives every event a function with a descriptive name.

The latter is even more important than the former.

However, I also noticed that they frequently use nested functions that return JSX instead of defining separate components.

const renderItem = React.useCallback(
  ({ item }: { item: YieldedItem }) => {
    if (item === REPLY_PROMPT) {
      return <ComposePrompt onPressCompose={onPressReply} />
    } else if (item === DELETED) {
      return (
        <View style={[pal.border, pal.viewLight, styles.missingItem]}>
          <Text type="lg-bold" style={pal.textLight}>
            Deleted post.
          </Text>
        </View>
      )
    }

    // ...
  }
)

I try to avoid this at all cost because there’s an objectively better way to define them - a separate component. I’d rather pass down props to a component instead of relying on a closure over the main one.

In BlueSky’s case, they’re passing these render functions to other components, allowing them to render the JSX wherever needed. But you could pass a custom component instead.

So I’m not sure about the benefits of this approach, and I’d recommend avoiding it.

The last thing I noticed in BlueSky’s components is a minor detail - they don’t use the built-in hooks directly but reference them from the React object instead. I don’t know if it’s intentional, but it helps to notice which hook is a built-in one.

Oh, and if you browse through the components, don’t let the View and Text components throw you away. They are react-native components that get translated to web ones via react-native-web, as we mentioned at the beginning.

Summary

The biggest idea I’ve been thinking about recently is how to abstract domain logic from React components. One way to do so is through custom hooks. An alternative in BlueSky’s codebase is leveraging domain classes connected to the state management solution.

Seeing an actual React application running in production with such a simple structure was eye-opening. But it was also important for me to notice that moving domain logic away from the components is something other developers in the community are thinking about.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.