DEV Community

Marc Scholten
Marc Scholten

Posted on

An Alternative Approach to State Management with Redux

Redux is often used in React.js Apps to manage global state. Typically the redux store follows a similiar shape as the app's database schema. E.g. for a table tasks, you typically have a corresponding tasksReducer.

With Redux, your application data now lives in two places: the frontend and the backend:

  • In the frontend we need to have one central place to keep the data consistent. E.g. when we change the title attribute of a task object in our app, we want this change to be visible directly in all react components that display this title attribute. Without redux the app might accidentally still show the old title attribute in other components.
  • The backend provides the actual single source of truth for the data.

A lot of work when building Single Page Apps is spent on keeping these two systems in sync: When you add a new task object inside your app, you first add it to your redux store, so it's visible inside the UI, and you also make an API call to your Backend. When the Backend API call fails, you want to remove the record from your redux store again, otherwise your local state get's out of sync with the source of truth.

Keeping these two systems, backend and frontend, in sync is fundamentally hard as itโ€™s dealing with distributed system problems.

Inspiration from Multi Page Apps

Instead of manually writing this state management code for every table in our project, we can massively simplify the problem by rethinking our approach.

One direction where we can take inspiration from is Multi Page Apps. A multi page app typically is mucher simpler than a single page app. A multi page app is always rendered directly on the state of the SQL database. E.g. when building a simple PHP app, you fetch some data from the database and then render the HTML based on that data. There's no second system like redux. This is one reason that makes Multi page Apps a lot simpler to build.

<?php
// Fetch data
$query = "SELECT * FROM tasks ORDER BY created_at";

$statement = $conn->prepare($query);
$statement->execute();

$tasks = $statement->fetchAll();

// Render HTML
echo "<div>";
echo "<h1>Tasks</h1>";
foreach ($tasks as $task) {
    echo "<div>" . htmlspecialchars($task['title']) . "</div>";
}
echo "</div>";
Enter fullscreen mode Exit fullscreen mode

Can we apply this principle to Single page apps as well?

Let's try it.

Querying from the Frontend

First we need a way to describe queries. This could look like this:

const theQuery = query('tasks').orderBy('createdAt');
Enter fullscreen mode Exit fullscreen mode

Unlike the Multi Page App, in our single page app the views need to re-render when the underlying data changes. So we also need a way for the client to be notified by the server when the underlying database record of a query have changed, so that the component can be re-rendered.

With React this is typically solved using a Hook. Let's asume we've built a custom useQuery hook that magically refreshes whenever the database records that are returned by that hook are changed. It would look like this:

function Tasks() {
    // Fetch data
    // and magically keep the data fresh
    const tasks = useQuery(query('tasks').orderBy('createdAt'));

    // Render
    return <div>
        <h1>Tasks</h1>
        {tasks?.map(task => <div>{task.title}</div>)}
    </div>
}
Enter fullscreen mode Exit fullscreen mode

You can see that this structure closely follows the structure of the above PHP code.

The useQuery always returns the latest database state, and automatically refreshes when a record is changed in the database. With this we've now actually archived the same goal of consistency across the application state. The goal we've set out to solve initially with redux. Instead of rendering the view based on the redux store, we now render the view based on the actual database. Just like good old PHP does.

Mutations

With a useQuery that automatically refreshes when the underyling data changes, we can do mutations in any way we want. We could call mutations with a manual REST Api, with custom functions like createRecord(tableName, record) or updateRecord(tableName, id, patch), or microservices.

As long as the mutations write to the database, the database changes will be picked up by our useQuery automatically.

Thin Backend

We've put the above API ideas of useQuery and query into work with Thin Backend. Thin provides you an easy way to keep your backend just a thin layer over your data, while providing an interactive, rich experience in the frontend.

Thin Backend provides a useQuery hook that automatically subscribes to changes inside a Postgres table and notifies any useQuery calls about those changes. To keep data secure and private for each user, we use Postgres Policies to only grant access if your policies say so.

Thin also provides simple functions to create, update and delete database records:

const task = await createRecord('tasks', { title: 'New task' });
await updateRecord('tasks', task.id, { title: 'Updated title' });
await deleteRecord('tasks', task.id);
Enter fullscreen mode Exit fullscreen mode

Here's how a simple todo app looks like with these APIs:

import { query, createRecord } from 'thin-backend';
import { useQuery } from 'thin-backend-react';

function Tasks() {
    // `useQuery` always returns the latest records from the db
    const tasks = useQuery(query('tasks').orderBy('createdAt'));

    return <div>
        {tasks.map(task => <Task task={task} key={task.id} />)}
    </div>
}

function Task({ task }) {
    return <div>{task.title}</div>
}

function AddTaskButton() {
    const handleClick = () => {
        const task = { title: window.prompt('Title:') };

        createRecord('tasks', task);
    }

    return <button onClick={handleClick}>Add Task</button>
}

function App() {
    // No need for state management libs
    // `useQuery` automatically triggers a re-render on new data
    return <div>
        <Tasks />
        <AddTaskButton />
    </div>
}
Enter fullscreen mode Exit fullscreen mode

You can run a live demo of that code here.

Once you start using the above APIs, you'll experience that it can greatly simplify managing database state in your frontend. At the end you might not even need redux anymore at all.

Conclusion

By rendering the view based on our actual database state instead of a second system like redux, we can radically simplify state management in modern single page frontends.

If you're curious, give it a try at thin.dev.

Here's what people who tried it out said about Thin:

Overall using thin-backend has been one of the most delightful experiences I've had making an SPA with a simple backend. The developer experience with the generated TypeScript types is particularly awesome!

From a developer point of view with Thin it feels like you can move directly from a schema to a user interface without having to spend a single line of code on network configuration or intermediate application layer logic.

Discussion (0)