Skip to content

Latest commit

 

History

History
392 lines (308 loc) · 9.95 KB

File metadata and controls

392 lines (308 loc) · 9.95 KB

Advanced React Todos App

Before you start, make sure you had a look at the Simple React Todos App.

If you understood the simple example, you are prepared to go a step further.

We are going to solve the same problem with the same outcome from a user's perspective, but with an entirely different implementation.

If you take a step back and look at the design of the simple example you recognize that our backend class reflects something like a manager for our todo items. The Todo.js backend-class provides indirect access to the items through an index generated by the backend. The individual todo items can not directly be accessed.

While this is completely fine here, you could think of more sophisticated cases, where the corresponding thing to the todo item may not only carry data information but posses much more business logic, inner-state, and life-cycle. In such a case a manager will quickly become tedious and complex to write as it must wrap all added functionality.

With this in mind we start the second example. As before we first create a new directory, (e.g. vrpc-react-todos-2) and run:

mkdir backend
cd backend
npm init -f -y
npm install vrpc
mkdir src

in there.

Backend

As we want to directly control Todo items instead of doing this indirectly through a manager, we write our backend like this:

src/Todo.js

const EventEmitter = require('events')

class Todo extends EventEmitter {
  constructor (text) {
    super()
    this._data = { text, completed: false }
  }

  getData () {
    return this._data
  }

  toggleCompleted () {
    this._data.completed = !this._data.completed
    this.emit('update', this._data)
  }
}

module.exports = Todo

So, what we have done is writing a class whose instances directly reflect individual Todo items. Moreover, have we added a new capability of emitting an update event whenever the item's state is changed (mutated).

Again, we register this class through VRPC to make it remotely callable:

index.js

const { VrpcAdapter, VrpcAgent } = require('vrpc')
// Register class "Todos" to be remotely callable
VrpcAdapter.register(require('./src/Todo'))

async function main () {
  try {
    const vrpcAgent = new VrpcAgent({
      agent: 'example-advanced-todos-agent',
      domain: 'vrpc'
    })
    await vrpcAgent.serve()
  } catch (err) {
    console.log('VRPC triggered an unexpected error', err)
  }
}

// Start the agent
main()

Now, it's interesting to see how it can be used via VRPC Live. Select vrpc as domain and you will see the Todo class under your agent name.

As VRPC can itself manage the life-time of instances (through the injected methods create and delete) you can create a Todo item by calling create with the argument keep coding.

This will create a shared instance of the Todo class and pass 'keep coding' to the constructor.

Once created, you can call all member functions. In the frontend implementation we will make use of this VRPC feature.

Frontend

Let's quickly setup the project by running the following lines (from within the root directory):

npx create-react-app frontend
cd frontend
npm install vrpc
npm install react-vrpc
mkdir -p src/components

and modifying the index.js file:

src/index.js

import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './components/App'
import * as serviceWorker from './serviceWorker'
import { createVrpcProvider } from 'react-vrpc'

const broker = process.env.REACT_APP_BROKER_HOST
  ? `ws://${process.env.REACT_APP_BROKER_HOST}:8080/mqtt`
  : 'wss://vrpc.io/mqtt'

const VrpcProvider = createVrpcProvider({
  broker,
  domain: 'vrpc',
  backends: {
    todos: {
      agent: 'example-advanced-todos-agent',
      className: 'Todo'
    }
  }
})

const root = createRoot(document.getElementById('root'))
root.render(
  <VrpcProvider>
    <App />
  </VrpcProvider>
)

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

Note the difference to the simple example! We are specifying agent and className but NOT instance and/or args.

This makes the todos backend a manager for its own instances.

src/components/App.js

import React from 'react'
import AddTodo from './AddTodo'
import ShowTodos from './ShowTodos'
import { useBackend } from 'react-vrpc'

export default function App () {
  const [, error] = useBackend('todos')

  if (error) return `Error! ${error.message}`

  return (
    <>
      <h1>Todo List</h1>
      <AddTodo />
      <ShowTodos />
    </>
  )
}

This file is identical to the one in the simple example, so quickly to the next.

src/components/AddTodo.js

import React from 'react'
import { useBackend } from 'react-vrpc'

export default function AddTodo () {
  const [todos] = useBackend('todos')

  function handleSubmit (e) {
    e.preventDefault()
    const { value } = input
    input.value = ''
    if (!value.trim()) return
    const id = Date.now().toString()
    todos.create(id, { args: [value] })
  }

  let input
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input ref={node => (input = node)} />
        <button type='submit'>Add Todo</button>
      </form>
    </div>
  )
}

Here, you can see the managing aspect of the todos backend. The two lines:

const id = Date.now().toString()
await backend.create(id, { args: [value] })

are the important ones. First, we create an id (using a timestamp), then we create a new instance (on the backend!) of a Todo class by means of the create method.

Not used here, but just for completeness: there is a get(id) and delete(id) method available as well.

When a new instance is created, all todos backends will automatically be updated, so no need to explicitly call refresh() here.

src/components/ShowTodos.js

import React, { useState } from 'react'
import { useBackend } from 'react-vrpc'
import TodoItem from './TodoItem'
import Filter from './Filter'

export default function ShowTodos () {
  const [filter, setFilter] = useState('all')
  const [{ ids }] = useBackend('todos')
  return (
    <div>
      <ul>
        {ids.map(id => (
          <TodoItem key={id} id={id} filter={filter} />
        ))}
      </ul>
      <Filter onClick={setFilter} filter={filter} />
    </div>
  )
}

This component got much shorter, as we moved more logic to the TodoItem. Its main purpose is rendering out the list while applying the correct filter to it.

Note, how the available ids can be retrieved from the todos backend:

const { ids } = todos

and that it does not matter what their values are (as long as they are unique).

src/components/TodoItem.js

import React, { useEffect, useState } from 'react'
import { useBackend } from 'react-vrpc'

export default function TodoItem ({ id, filter }) {
  const [todo] = useBackend('todos', id)
  const [data, setData] = useState({ text: '', completed: false })

  useEffect(() => {
    if (!todo) return
    todo.getData().then(data => setData(data))
    const handleUpdate = data => setData(data)
    todo.on('update', handleUpdate)
    return () => {
      todo.off('update', handleUpdate).catch(() => {})
    }
  }, [todo])

  function handleClick () {
    todo.toggleCompleted()
  }

  if (filter === 'completed' && !data.completed) return null
  if (filter === 'active' && data.completed) return null

  return (
    <li
      onClick={handleClick}
      style={{ textDecoration: data.completed ? 'line-through' : 'none' }}
    >
      {data.text}
    </li>
  )
}

This component exactly reflects a single instance of the Todo class, you could say it is its "React-Component-Twin".

The nicety about this design is that the logic can stay in the lowest hierarchy component, hence minimizing re-rendering and maximizing performance.

The magic starts with:

const [todo] = useBackend('todos', id)

which provides us with a proxy (todo) to a specific Todo instance of the backend.

The rest of the code is obvious besides one feature that is new here:

useEffect(() => {
  if (!todo) return
  todo.getData().then(data => setData(data))
  const handleUpdate = data => setData(data)
  todo.on('update', handleUpdate)
  return () => {
    todo.off('update', handleUpdate).catch(() => {})
  }
}, [todo])

This effect hook's purpose is to make the UI automatically update whenever the backend is updated - even when done through a third party.

You can see that it works by simply starting a second UI in a another browser tab and watching them synchronizing each other nicely, or you manipulate the backend using VRPC Live.

IMPORTANT

Don't be afraid using events with react-vrpc, it actually is the recommended way to synchronize your frontend state with the backend state.

The VRPC API fully implements NodeJS' event module, just remember to make your backend an event emitter by inheriting the EventEmitter class.

The last component is boring and did not change from the simple example:

src/components/Filter.js

import React from 'react'

export default function Filter ({ onClick, filter }) {
  return (
    <div>
      <span>Show: </span>
      <button
        onClick={() => onClick('all')}
        disabled={filter === 'all'}
        style={{ marginLeft: '4px' }}
      >
        All
      </button>
      <button
        onClick={() => onClick('active')}
        disabled={filter === 'active'}
        style={{ marginLeft: '4px' }}
      >
        Active
      </button>
      <button
        onClick={() => onClick('completed')}
        disabled={filter === 'completed'}
        style={{ marginLeft: '4px' }}
      >
        Completed
      </button>
    </div>
  )
}

Voila, there are we again!

Application finished and working, thanks for reading, enjoy playing!