State management in JavaScript applications has always been a developer pain point. React is no exception. The simplest way to manage state in react is its built-in component state API - setState. But it is often insufficient in handling the requirements of complex applications. That's where different solutions come in - Flux was one of the first, then came Redux and swept everyone off their feet. Another big one is MobX. But these solutions have their own ways of doing things, their own concepts and best practices that you need to wrap your head around before using them. If you are tired of all the tedious setup and boilerplate code needed to work with libraries like Redux, then Unstated might be the solution you have been looking for.
Unstated is very simple and easy to understand if you are already comfortable with React and its ways. It builds upon existing React concepts like setState, class structure, context and common patterns like render props. Its API is extremely simple and small as it provides only three things - a Container class, a Subscribe component and a Provider Component.
We will learn how to use Unstated by creating a "Language Filter" app (because I think no one wants another of those counter or todo apps). It is a very simple app consisting of two main parts -
1. Adding and removing words that need to be filtered.
2. Filtering the sentence that the user inputs.
The app we will build is hosted on Codesandbox and Github. Feel free to fork it and play around.
Note: This article assumes the reader has experience using React and ES6+ code.
Getting Started
We will use Codesandbox to bootstrap a new React project right in the browser. We will create two directories under src - components for all the react components and containers for the unstated state containers. We will use Spectre CSS library to make our small app look presentable.
Container
In Unstated, we manage state by extending the Container
class. Extending and using this Container
class to build a state container is very similar to extending React.Component
for a component class, with the exception of the render method.
We will start by creating two Container classes -
1. SentenceContainer
- for managing the sentence that is given and the filtered version of the sentence.
2. WordlistContainer
- for managing the adding and removing of words that need to be filtered.
/* ./containers/SentenceContainer.js */
import { Container } from 'unstated';
class SentenceContainer extends Container {
state = {
filteredSentence: '',
};
filterSentence = wordlist => e => {
const filteredSentence = e.target.value
.trim()
.split(' ')
.map(word => {
if (wordlist.includes(word.toLowerCase())) {
return '*'.repeat(word.length);
}
return word;
})
.join(' ');
this.setState({ filteredSentence });
};
}
export default SentenceContainer;
In this, we import and extend the Container
class. As you can see, it is pretty similar to a React class component without the render
method. We first initialize the state
with a single property filteredSentence
with the default value of an empty string ''
. And we also have a method that will use the wordlist
passed to it to filter the given sentence. We are using the experimental public field class syntax (a.k.a the arrow function binding shenanigans). In the method, we are utilizing closure to hold the wordlist
array that contains all the banned words. Then, we grab the sentence value from the e.target
object passed to us from the event handler and we check the sentence for banned words. If any such words are there, we replace them with * (asterisks). Lastly, we use setState
(works pretty much the same way as React's setState) to update the state.
/* ./containers/WordlistContainer.js */
import { Container } from 'unstated';
class WordlistContainer extends Container {
constructor(...words) {
super();
this.state = {
words: words,
};
}
addWord = word => {
const wordToAdd = word.toLowerCase();
this.setState(({ words }) => {
if (!words.includes(wordToAdd)) {
const newWords = [...words, wordToAdd];
return { words: newWords };
} else {
return null;
}
});
};
removeWord = word => () => {
this.setState(({ words }) => {
if (words.includes(word)) {
const wordIndex = words.findIndex(w => w === word);
const newWords = [
...words.slice(0, wordIndex),
...words.slice(wordIndex + 1),
];
return { words: newWords };
} else {
return null;
}
});
};
}
let wordList = new WordlistContainer('shit', 'damn');
export default wordList;
Again, we extend the Container
class but this time we use the constructor
to pass in the initial state
we want. We again have two methods -
1. addWord
- it accepts a word
parameter and adds it to the words
array in the state if it does not already exist in the array. This time we are using the callback function form of setState
call. The return value is used to update the state, if null
is returned nothing is updated (hence, causes no rerender).
2. removeWord
- it is quite similar to addWord
but it removes the word instead of adding it.
One crucial change to notice is, in case of the previous container we exported out the class itself. But for this one, we created an instance and exported that specific instance out. Even though we used this just for passing some optional defaults, it can also be used for interesting things like dependency injection (when passed to inject
prop on Provider
component).
Subscriber
We have created the state and the logic to manage the state already. Now, we must use the state to render something for our user. In unstated, the Subscriber
component uses the render props pattern to expose the state data and methods contained within a container to a react component.
Let's get started on two react components that are named complimentarily to their respective containers.
/* ./components/Wordlist.js */
import React, { Component } from 'react';
import { Subscribe } from 'unstated';
import wordList from '../containers/WordlistContainer';
class Wordlist extends Component {
wordInput = React.createRef();
handleClick = addWord => () => {
if (this.wordInput.current.value) {
addWord(this.wordInput.current.value);
this.wordInput.current.value = '';
}
};
render() {
return (
<Subscribe to={[wordList]}>
{({ state: { words }, addWord, removeWord }) => (
<div className="container">
<h5>Banned Words</h5>
<div className="wordlist">
{words.map(word => (
<Word key={word} word={word} removeWord={removeWord} />
))}
</div>
<div className="input-group words-form">
<input
type="text"
ref={this.wordInput}
className="form-input word-input"
name="word"
/>
<button
className="btn btn-primary add"
onClick={this.handleClick(addWord)}
>
Add
</button>
</div>
</div>
)}
</Subscribe>
);
}
}
const Word = ({ word, removeWord }) => (
<span className="chip word">
{word}
<button
href="#"
className="btn btn-clear"
aria-label="Remove"
onClick={removeWord(word)}
/>
</span>
);
export default Wordlist;
We create a Wordlist
component that uses the Subscribe
component from unstated. Subscribe component has a to
prop that accepts an array value of all the containers that need to be subscribed to. It then passes the respective instances to the render prop function as parameters.
Inside the component, we create a ref
(using the new React.createRef
API) and a handleClick
method to read the text value of the input and pass it to the addWord
method we get from the wordList
container instance. We use the words
array on the state object to render the banned words and also attach the removeWord
method to each of the Word components' onClick
event.
/* ./components/Sentence.js */
import React from 'react';
import { Subscribe } from 'unstated';
import wordList from '../containers/WordlistContainer';
import SentenceContainer from '../containers/SentenceContainer';
const Sentence = () => (
<Subscribe to={[wordList, SentenceContainer]}>
{(
{ state: { words } },
{ state: { filteredSentence }, filterSentence },
) => (
<div className="container">
<h5>Filtered Sentence</h5>
<pre className="code filtered-sentence">{filteredSentence}</pre>
<div className="form sentence-form">
<input
className="form-input"
onChange={filterSentence(words)}
type="text"
name="sentence"
placeholder="Enter sentence here..."
/>
</div>
</div>
)}
</Subscribe>
);
export default Sentence;
In the Sentence
component, we subscribe to not one but two container instances, including the wordList
instance we already subscribed to in the previous React component. Also, the parameters passed to the render props callback are in the order in which we pass the containers in the to
array prop of the Subscriber
component.
Inside this component, we use the words
array from the wordList
state to pass as parameter to the filterSentence
method from Sentence
container to update the filterSentence
which in turn is used to render the filtered sentence. This is a good example of how to use state and methods from different containers coherently.
Provider
Last but not the least, we need to nest all the components using Subscribe
under the Provider
component. Provider
is needed to keep track of all the container instances internally.
/* ./index.js */
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'unstated';
import Sentence from ./components/Sentence';
import Wordlist from './components/Wordlist';
import './styles.css';
function App() {
return (
<div className="app">
<h1>Unstated Language Filter</h1>
<Sentence />
<div className="divider" />
<Wordlist />
</div>
);
}
const rootElement = document.getElementById('root');
ReactDOM.render(
<Provider>
<App />
</Provider>,
rootElement,
);
In the index file, we create the App
component and render Sentence
and Wordlist
components as its children. Finally, wrap the App
component in the Provider
Component and use ReactDOM.render
to mount the app on the DOM element with id root
.
Conclusion
Unstated is one of the most elegant state management solutions out there. So, next time when you find yourself reaching out for libraries like Redux or MobX give Unstated a try instead. It's fairly easy to get started with and works perfectly for a lot of use cases.