Build a TODO app

In this chapter you will build a working TODO list from scratch. Along the way you will learn how to use signals, subscribe to data, and update the database -- all in real time.

Start with a clean page

Open the home page file and replace its content. In a default Expo template with tabs, this is app/(tabs)/index.tsx. If your project does not use tabs, it is app/index.tsx.

return (
  <>
    {/* Your content will go here */}
  </>
)

Add layout

StartupJS uses its own components instead of HTML elements. Content is a layout wrapper that constrains width and adds optional padding.

import { observer } from 'startupjs'
import { Content, Span } from 'startupjs-ui'

export default observer(function PHome () {
  return (
    <Content padding>
      <Span h1>TODO List</Span>
    </Content>
  )
})

Content with the padding prop adds comfortable spacing around your content. Span with the h1 prop renders a top-level heading. You can explore these components in the docs sidebar under Components.

Add a card

Card is a container that displays content with a border. In React Native, you cannot place raw text directly inside a view -- always wrap text with Span.

import { observer } from 'startupjs'
import { Content, Card, Span } from 'startupjs-ui'

export default observer(function PHome () {
  return (
    <Content padding>
      <Span h1>TODO List</Span>
      <Card>
        <Span>Learn StartupJS</Span>
      </Card>
    </Content>
  )
})

Add styles

Use styleName to connect a component to a style class defined in the accompanying index.styl file:

<Span h1 styleName='title'>TODO List</Span>
// index.styl
.title
  color purple

styleName works like className in HTML, but it maps to Stylus classes. You can pass a string, an array, or an object for conditional styles:

<Button styleName={['primary', { disabled: isDisabled }]} />

Fetch and display data

Now for the interesting part. To display data from the database, you need to subscribe to it. StartupJS uses signals -- reactive pointers that track a piece of data and update your UI when it changes.

Use the useSub hook to subscribe to a collection:

import { observer, $, useSub } from 'startupjs'
import { Content, Card, Span } from 'startupjs-ui'

export default observer(function PHome () {
  const $todos = useSub($.todos, {})

  return (
    <Content padding>
      <Span h1>TODO List</Span>
      {$todos.map($todo => (
        <Card key={$todo.getId()}>
          <Span>{$todo.title.get()}</Span>
        </Card>
      ))}
    </Content>
  )
})

Here is what is happening:

  • $ is the root signal -- the entry point to all your data.
  • $.todos points to the todos collection in the database.
  • useSub($.todos, {}) subscribes to all documents in that collection. The {} is the query (an empty query means "everything").
  • $todos is a query signal. You can iterate over it with .map(). Each $todo in the callback is a signal pointing to a single document.
  • $todo.title.get() reads the value of the title field. Use .get() whenever you need the plain value.
  • $todo.getId() returns the document's unique identifier (used as the React key). Every document gets an auto-generated ID when you call .add(). Use .getId() (not .id) to retrieve it.

The list is empty right now because there are no documents in the database yet. We will add them soon.

Add a checkbox and delete button

Let's make each todo item interactive. We will show a checkbox to mark it as done and a button to delete it.

import { observer, $, useSub } from 'startupjs'
import { Button, Card, Checkbox, Content, Div, Span } from 'startupjs-ui'
import { faTimes } from '@fortawesome/free-solid-svg-icons'

export default observer(function PHome () {
  const $todos = useSub($.todos, {})

  return (
    <Content padding>
      <Span h1>TODO List</Span>
      {$todos.map($todo => (
        <Card key={$todo.getId()}>
          <Div row align='between' vAlign='center'>
            <Checkbox
              value={$todo.completed.get()}
              onChange={value => $todo.completed.set(value)}
            />
            <Span>{$todo.title.get()}</Span>
            <Button
              icon={faTimes}
              iconColor='error'
              size='s'
              onPress={() => $todo.del()}
            />
          </Div>
        </Card>
      ))}
    </Content>
  )
})

Key points:

  • Reading data: $todo.completed.get() returns the current value of completed (true or false).
  • Writing data: $todo.completed.set(value) updates the completed field in the database. The change syncs to all connected clients automatically.
  • Deleting a document: $todo.del() removes the entire todo from the database.
  • Div with row lays out children horizontally. align='between' spaces them out like CSS justify-content: space-between. vAlign='center' vertically centers them.

Add new tasks

To create new todos, add a text input and a submit button. We will use a local signal to store the input value.

import { observer, $, useSub } from 'startupjs'
import { Button, Card, Checkbox, Content, Div, Span, TextInput } from 'startupjs-ui'
import { faTimes } from '@fortawesome/free-solid-svg-icons'

export default observer(function PHome () {
  const $todos = useSub($.todos, {})
  const $newTitle = $('')

  async function addTodo () {
    const title = $newTitle.get().trim()
    if (!title) return
    await $.todos.add({ title, completed: false })
    $newTitle.set('')
  }

  return (
    <Content padding>
      <Span h1>TODO List</Span>

      <Div row vAlign='center'>
        <TextInput
          style={{ flex: 1 }}
          value={$newTitle.get()}
          onChangeText={value => $newTitle.set(value)}
          placeholder='What needs to be done?'
        />
        <Button onPress={addTodo}>Add</Button>
      </Div>

      {$todos.map($todo => (
        <Card key={$todo.getId()}>
          <Div row align='between' vAlign='center'>
            <Checkbox
              value={$todo.completed.get()}
              onChange={value => $todo.completed.set(value)}
            />
            <Span>{$todo.title.get()}</Span>
            <Button
              icon={faTimes}
              iconColor='error'
              size='s'
              onPress={() => $todo.del()}
            />
          </Div>
        </Card>
      ))}
    </Content>
  )
})

Here is what is new:

  • $('') creates a local signal with an initial value of an empty string. Note: $ by itself is the root signal (your database). $() called as a function creates a local signal -- a reactive variable that lives only in this component, similar to React's useState.
  • $newTitle.get() reads the current input value.
  • $newTitle.set(value) updates it when the user types.
  • $.todos.add({ title, completed: false }) creates a new document in the todos collection. The add method generates a unique ID automatically and returns it. We await it to make sure the document is created before clearing the input.

Complete code

Here is the full component:

import { observer, $, useSub } from 'startupjs'
import { Button, Card, Checkbox, Content, Div, Span, TextInput } from 'startupjs-ui'
import { faTimes } from '@fortawesome/free-solid-svg-icons'

export default observer(function PHome () {
  const $todos = useSub($.todos, {})
  const $newTitle = $('')

  async function addTodo () {
    const title = $newTitle.get().trim()
    if (!title) return
    await $.todos.add({ title, completed: false })
    $newTitle.set('')
  }

  return (
    <Content padding>
      <Span h1>TODO List</Span>

      <Div row vAlign='center'>
        <TextInput
          style={{ flex: 1 }}
          value={$newTitle.get()}
          onChangeText={value => $newTitle.set(value)}
          placeholder='What needs to be done?'
        />
        <Button onPress={addTodo}>Add</Button>
      </Div>

      {$todos.map($todo => (
        <Card key={$todo.getId()}>
          <Div row align='between' vAlign='center'>
            <Checkbox
              value={$todo.completed.get()}
              onChange={value => $todo.completed.set(value)}
            />
            <Span>{$todo.title.get()}</Span>
            <Button
              icon={faTimes}
              iconColor='error'
              size='s'
              onPress={() => $todo.del()}
            />
          </Div>
        </Card>
      ))}
    </Content>
  )
})

What you learned

  • $ is the root signal that gives you access to all collections.
  • useSub($.collection, query) subscribes to database documents and returns a query signal.
  • Query signals are iterable -- use .map() to render lists.
  • Each item in a query signal is a document signal. Use .get() to read fields, .set() to update them, and .del() to delete.
  • $('initial value') creates a local signal for component-level state.
  • $.collection.add({...}) creates a new document.
  • All data changes sync to every connected client automatically.

Open the app in two browser tabs and try adding, checking, and deleting todos. You will see both tabs update at the same time -- that is real-time sync in action.