DEV Community

Cover image for Ripple, React shared state with less code...
Ampla Network
Ampla Network

Posted on

Ripple, React shared state with less code...

Introducing Ripple, Your Go-To State Management Library for React

Welcome to Ripple, the latest innovation in state management libraries for React. Designed with simplicity and performance in mind, Ripple aims to simplify the way you handle states in your React applications.

Ripple official website

Unlike more complex state management solutions like Redux or MobX, Ripple offers an intuitive, easy-to-use interface and delivers unrivaled speed, streamlining your development workflow and boosting your productivity.

Whether you're a seasoned React developer or just getting started, Ripple provides a straightforward approach to state management that's efficient and effective.

Riding the Wave with Ripple

Ripple is an intuitive shared state management library for React that keeps things simple. Think of it as Recoil's kindred spirit, offering seamless updates via hooks or from outside the React environment. The philosophy behind Ripple is to let the components focus on rendering, and leave the state updates to outside handlers.

Let's say, you need to update your state based on a WebSocket event, asynchronously from a Promise, or maybe from a service class... Ripple’s got your back! It even empowers you to trigger state updates from within a High Order Component using the right hook.

Let's delve deeper into how Ripple operates:

The Ripple Lake

In Ripple, you'll be dealing with the concept of a 'Ripple Lake.' A Lake is essentially a repository of data, a tranquil space where all your ripples belong. To implement a ripple in your component, you declare it in a Ripple Lake. The good news is, you can create as many Lakes as you wish.

Start by initializing a new file in your project, say ripple-lake.ts, and import the createRipples function from Ripple.

// ripple-lake.ts
import { createRipples } from "@m-c2/ripple";
Enter fullscreen mode Exit fullscreen mode

To initialize a Lake, call createRipples and add all the ripples you want to use:

// Ripple definitions
const globalRipple = {
  value1: "value1"
};

const ripple1 = {
  value1: "value1"
};

// Lake definition
const [hooks, services] = createRipples({
  globalRipple,
  ripple1
});

// Export the hooks and services
export const rippleHooks = hooks;
export const rippleServices = services;
Enter fullscreen mode Exit fullscreen mode

The createRipples method returns an array with two elements - the hooks and the external updaters. Hooks is an object containing as many hooks as ripples you've declared. Updaters, on the other hand, is an object housing functions that allow you to update the ripples from anywhere in your code.

This is where the magic of TypeScript comes into play. The hooks and updaters will be typed automatically based on the ripple type.

Ripple in Action Inside Components

To use Ripple inside your components, import the hooks from your ripple-lake and destructure them. This will give you access to the specific ripples you need.

Here's an example using useGlobalRipple:

// component-one.ts
import { rippleHooks } from "./ripple-lake";
const { useGlobalRipple } = rippleHooks;

const ComponentOne = () => {
  const [globalRipple, setGlobalRipple] = useGlobalRipple();

  return <div>{globalRipple.value1}</div>;
};

Enter fullscreen mode Exit fullscreen mode

You can also destructure the hooks directly in the component declaration:

// component-one.ts
import { rippleHooks } from "./ripple-lake";
const { useGlobalRipple } = rippleHooks;

const ComponentOne = () => {
  const [globalRipple, setGlobalRipple] = useGlobalRipple();
  const { value1 } = globalRipple;

  return <div>{value1}</div>;
};

Enter fullscreen mode Exit fullscreen mode

The beauty of Ripple is that every time you update globalRipple from any component or anywhere outside React, all the components that use it will be updated automatically. This does away with the need for any manual registration for updates, streamlining the refresh of data and ensuring only visible components using the hook are updated.

A Deep Dive into Ripples

A Ripple in the Lake of our state management tool represents an object, akin to an Atom in Recoil. Ripples are the fundamental units of state in our Lake, and each one contains a part of the overall application state. They are declared in the Lake using the createRipples function, taking a name and an initial state.

When you access a Ripple from a component, you receive a copy of the Ripple state and a set function to update the state. You can modify this local copy of the Ripple in your component, but keep in mind that these changes won't be reflected back in the Lake.

Why is that? The Ripple source is immutable. Changes to a Ripple are made through the updater and only then are these changes propagated to the Lake. Subsequently, these modifications spread to all the components that use the Ripple.

Let's illustrate this with a simple Ripple definition:

// my-lake.ts
... = createRipples({
  counter: { // Counter ripple
    count: 0,
  },
});

Enter fullscreen mode Exit fullscreen mode

We've created a basic Ripple named counter with a single property, count, initialized at 0.

Ripple in the Component

This counter Ripple can be used within a component as follows:

// MyComponent.tsx
import { rippleHooks } from './my-lake';
const { useCounter } = rippleHooks;

const MyComponent = () => {
  const [counter, /*...updaterHook...*/] = useCounter();

  return (
    <div>
      <p>Counter: {counter.count}</p>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

The useCounter hook returns a tuple consisting of the Ripple state and an updater function.

Remember, we can't directly modify the source of the Ripple (like counter.count++) and expect it to update everywhere. But we can perform a local modification and then call the updater function to propagate this change to the Lake. The source value remains unaltered, but the Lake gets updated with the new value.

Destructuring the Ripple

One of the key features of Ripple is its internal linking mechanism. You can destructure a Ripple and still be rerendered when the Ripple changes.

// MyComponent.tsx (destructuring)
import { rippleHooks } from './my-lake';
const { useCounter } = rippleHooks;

const MyComponent = () => {
  const [counter, setCounter] = useCounter();
  const { count } = counter;

  return (
    <div>
      <p>Counter: {count}</p>
      <button onClick={() => {
        counter.count++;
        setCounter();
      }}>Increment</button>
    </div>
  );
};

Enter fullscreen mode Exit fullscreen mode

In this case, although destructuring may look more verbose, it showcases the flexibility offered by Ripple. Local modifications can either be applied to the Lake or cancelled via the updater function.

Behind the Scenes

Under the hood, Ripple tracks local changes using a Proxy object. Therefore, a Ripple isn't a straightforward copy of the state object. While a Proxy works similarly to the actual object, there are subtle differences, like Array.isArray not functioning as expected with an array.

When a Ripple is used, the Lake creates a Proxy object to track the changes. This approach ensures that even complex Ripple structures with deeply nested objects don't hamper performance. We track changes, so only modified properties get updated at once or cancelled as needed.

The primary goal here is to minimize the need for developers to include business logic inside components. Let your components focus on rendering, and let Ripple handle the state management.

Ripple Updates from a Component

Inside a component, using a Ripple hook feels similar to using React's useState, with one key distinction: the hook takes the name of a Ripple, not an initial state. You use the Ripple to access values and the updater to change the Ripple's state.

The updater can be used with or without parameters, depending on your intentions. By default, invoking the updater without parameters applies all current modifications made to the Ripple object.

Example: Using the Updater

Consider this simple example, where we define a counter Ripple and subsequently utilize it in a component:

// my-lake.ts
import { createRipples } from "@m-c2/ripple";

const lake = createRipples({
  counter: { count: 0 }
});

export const rippleHooks    = lake[0];
export const rippleServices = lake[1];
Enter fullscreen mode Exit fullscreen mode

In this scenario, we directly modify the counter object, then invoke the updater without any parameters. This call applies all modifications made to the Ripple object.

Updater Parameters

While the updater can be invoked without parameters to apply all modifications, you can also provide parameters to apply specific changes. Here's a breakdown of the different parameter options:

Function Parameter value Description Trigger a render
setCounter void Apply all pending changes true
setCounter string restore Cancel the current local modification false
setCounter string reset Reset the ripple to its initial state true
setCounter string, TRipple replace, Object Replace the current ripple state with a new one, does not impact the initial state in case of reset true

And here's how you can use them:

// Apply all pending changes
setCounter();

// Cancel the current local modification
setCounter('restore');

// Reset the Ripple to its initial state
setCounter('reset');

// Replace the current Ripple state with a new one,
// does not impact the initial state in case of reset
setCounter('replace', { count: 0 });

Enter fullscreen mode Exit fullscreen mode

Parent-Child Approach

Since a Ripple is shared between all components that use it, updates can be made from any of these components. This allows you to adopt a Parent-Children approach effortlessly.

// Parent component
const Parent = () => {
  const [counter, setCounter] = useCounter();

  return (
    <MyComponent
      increment={() => {
        counter.count++;
        setCounter();
      }}

      reset={() => {
        setCounter("reset");
      }}
    />
  );
};

Enter fullscreen mode Exit fullscreen mode

External Updates of Ripples

Ripples are designed for use within a React application, but you can also update them externally. This flexibility allows you to design your application without compromises on architectural patterns.

Example: Using External Updates

Consider this example where we have a counter Ripple, a service to update this Ripple outside the React lifecycle, and a component that uses this service to update the counter.

Lake definition

// This defines the lake where the ripples will belong to.
import { createRipples } from "@m-c2/ripple";

const lake = createRipples({
  counter: { count: 0 }
});

// Export the hooks for component usage only
export const rippleHooks = lake[0];
// Export the services for external usage
export const rippleServices = lake[1];

Enter fullscreen mode Exit fullscreen mode

Service

// This defines the services that will be used
// to update the ripples outside the React lifecycle.
import { rippleServices } from "./Lake";
const { updateCounter } = rippleServices;

class CounterService {
  get counter() {
    return updateCounter();
  }

  increment() {
    this.counter(_ => { _.count++ });
  }

  reset() {
    this.counter(_ => "reset" );
  }
}

export const counterService = new CounterService();

Enter fullscreen mode Exit fullscreen mode

Component

// This is a simple component that will use the counter service
// to update the counter value.
import React, { CSSProperties } from 'react';
import { rippleHooks } from "./Lake";
import { counterService } from "./Service";

const { useCounter } = rippleHooks;

// Define some styles
const styles = {
  // Styles are omitted for brevity
}

const MyComponent = ({ increment, reset }) => {
  const [{ count }] = useCounter();

  return (
    <div style={styles.div}>
      <p style={styles.p}>Counter: {count}</p>
      <button
        style={styles.button}
        onClick={() => counterService.increment()}
      >Increment</button>
      <button
        style={styles.button}
        onClick={() => counterService.reset()}
      >Reset</button>
    </div>
  );
};

export default MyComponent;

Enter fullscreen mode Exit fullscreen mode

The Updater Service

Let's take a closer look at the updater service:

import { rippleServices } from "./Lake";
const { updateCounter } = rippleServices;

Enter fullscreen mode Exit fullscreen mode

The updateCounter function has different behaviors depending on its parameters:

Function Parameter type value Description Trigger a render
updateCounter void undefined Return the counter value no
updateCounter function handler: (ripple) => void \ "restore" \ "reset" \
// updateCounter(void) => ripple
const ripple = updateCounter();

// updateCounter(handler: (ripple) => void | "restore" | "reset" | typeof ripple) => void
updateCounter(ripple => { ... });

Enter fullscreen mode Exit fullscreen mode

The handler function also exhibits various behaviors depending on its return type:

Function return type return value Description Trigger a render
handler void undefined apply all pending modification to the ripple yes
handler string restore cancel pending modifications no
handler string reset reset the ripple to its initial state no
handler typeof ripple a new ripple replace the ripple with a new value. Does not replace the initial state yes
// apply all pending modification to the ripple
updateCounter(ripple => {
  ripple.count++;
});

// cancel pending modifications
updateCounter(ripple => "restore");

// reset the ripple to its initial state
updateCounter(ripple => "reset");

// replace the ripple with a new value. Does not replace the initial state
updateCounter(ripple => {
  return {
    count: 0,
  };
});

Enter fullscreen mode Exit fullscreen mode

This allows us to have external control over the Ripple's state, enabling various state manipulation outside the standard React component lifecycle.

Conclusion

Over the course of this article, we've delved into the details of using Ripples in a React application to manage state. Starting from the basic concepts of Ripples and Lakes, we journeyed through the creation and utilization of Ripples within React components, and the specifics of how their state can be updated both internally and externally.

Ripples provide a solution to state management that is flexible, simple, and comprehensive, allowing you to manage and propagate state changes with a minimum of boilerplate code. It leverages modern JavaScript features such as Proxies to track changes, ensuring that only the necessary parts of your application re-render when the state updates, improving the performance of your applications.

Key features of Ripples include:

  1. Immutability: Ripples remain immutable, preventing accidental state mutations and helping maintain predictable state management in your React applications.

  2. Flexibility: Ripples can be updated both internally, from inside a React component, or externally, providing flexibility and adaptability to various architectural patterns.

  3. Proxy Usage: Behind the scenes, Ripples use JavaScript Proxies to keep track of changes, which boosts performance and allows for optimal resource utilization.

  4. Conciseness: With its straightforward API and services, Ripples can simplify your codebase by reducing the boilerplate code commonly associated with state management.

In summary, the Ripple state management approach is a powerful tool for any developer working with React. It encapsulates many of the best principles of modern JavaScript and React, resulting in a state management system that's efficient, predictable, and developer-friendly.

Whether you're working on a small project or a large-scale application, Ripples can help streamline your state management and make your code cleaner, more maintainable, and easier to reason about.

Happy coding!

Top comments (0)