This exercise is a bit more challenging, that’s why I will add two git branches: one with the middle of the tutorial and one with the final product.
Although we are not going to use our custom Redux like store in the feature tutorials, it will be rewarding to be able to create your own ‘Redux like’ store.
You will understand how Redux work and why there is no magic happening there but a plain JavaScript is doing the job.
Local component state.
Let’s modify the Greeting component to use class instead of function and see how the state is tied up with the component.
./src/components/Greetings/index.js
import React, { Component } from 'react';
const styles = require('./styles.scss');
const CHANGE_USERNAME = 'CHANGE_USERNAME';
const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
class Greetings extends Component {
constructor(props) {
super(props);
this.state = {
userName: "no name",
editMode: false
}
}
doneEditUsername() {
let newName = document.querySelector('#inputField').value;
this.setState({userName: newName, editMode: false});
}
usernameChanged(el) {
this.setState({ userName: el.target.value });
}
render() {
let element = <h2 onClick={() =>{ this.setState({editMode: true}); }}>Hello: {this.state.userName}</h2>;
if(this.state.editMode)
element = <h2>Type new name:<input type="text" id='inputField' value={this.state.userName} onChange={(el) => { this.usernameChanged(el)}} /><button onClick={() =>{ this.doneEditUsername() }}>done</button></h2>
return (
<div>
<div className={styles.wrapper}>
{element}
</div>
</div>);
}
}
export default Greetings;
what we just did:
– we set up the initial state into the constructor with userName
set to “no name” and editMode
set to false, so the component just simply shows “Hello: no name”
– in the render function we are checking if component is in edit mode editMode
parameter and rendering either the <h2> tag with “Hello: [USERNAME] or the tag with editable <input> (line 23)
– we added doneEditUsername()
function hooked to the ‘done’ button, which will simply set the state userName
to whatever was the user input.
– also added usernameChanged()
function that will set the new username state so the input tag will reflect the new component name. Otherwise the input tag will be read only.
Now if you give it a try and play around switching to a different pages you will notice that the component won’t preserve the changes and it will default to “no name” each time when you switch back and forth to some other page. This happens because the local state is not connected to a store.
Creating persistent store.
In order for our component to preserve the changes, we need to have some sort of persistent store. There are many ways to achieve this but let’s explore the simplest one.
Use plain global object as a store.
This simply could be achieved by adding a store
object to the global window
object, and set up the default values (lines 13 – 16)
./src/components/App/index.js
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { ApolloProvider, graphql } from 'react-apollo';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import PageLayout from '../../containers/PageLayout';
import Store from '../../store';
import Reducers from '../../reducers';
import fetch from 'unfetch';
window.store = {
userName: "no name",
editMode: false
}
export default class App extends Component {
render() {
const GRAPHQL_URL = 'http://localhost:4001/graphql';
const client = new ApolloClient({
link: new HttpLink({ uri: GRAPHQL_URL, fetch: fetch }),
cache: new InMemoryCache()
});
return (
<ApolloProvider client={client}>
<Router>
<Switch>
<Route exact path="*" component={PageLayout} />
</Switch>
</Router>
</ApolloProvider>
);
}
}
and let’s hook our new store:
./src/components/Greetings/index.js
import React, { Component } from 'react';
const styles = require('./styles.scss');
const CHANGE_USERNAME = 'CHANGE_USERNAME';
const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
class Greetings extends Component {
constructor(props) {
super(props);
this.state = {
userName: window.store.userName,
editMode: window.store.editMode
}
}
doneEditUsername() {
let newName = document.querySelector('#inputField').value;
window.store.userName = newName;
window.store.editMode = false;
this.setState({userName: window.store.userName , editMode: window.store.editMode});
}
usernameChanged(el) {
window.store.userName = el.target.value;
this.setState({ userName: window.store.userName });
}
render() {
let element = <h2 onClick={() =>{ window.store.editMode = !window.store.editMode; this.setState({editMode: window.store.editMode}); }}>Hello: {this.state.userName}</h2>;
if(this.state.editMode)
element = <h2>Type new name:<input type="text" id='inputField' value={this.state.userName} onChange={(el) => { this.usernameChanged(el)}} /><button onClick={() =>{ this.doneEditUsername() }}>done</button></h2>
return (
<div>
<div className={styles.wrapper}>
{element}
</div>
</div>);
}
}
export default Greetings;
what we just did:
– we hooked window.store
to feed the local state
properties. (Lines 9 and 10). This way when the component is re-rendered next time it will get these values from the persistent global store.
– we changed doneEditUsername()
and usernameChanged()
to store the values of the userName
and editMode
into our global store and used these values to set the state and update the component.
– we updated the value of window.store.editMode
to be equal to the opposite value of itself so we could toggle the edit mode window.store.editMode=!window.store.editMode
(line 26)
Now our component preservers the state. Even if you toggle the edit mode, navigate to some different section and get back, the component will conveniently wait into the editing mode, with the input field ready to get the new user name.
Cool right now we have functional persistent state, but the way it was implemented is a mess!
Why ?
– We are setting the store
parameters from many different places in the code, which soon will become a nightmare to debug.
– we are mutating the state, one more reason why debugging will become almost impossible.
Let’s fix this.
Doing it the Redux way.
Let’s create a CreateStore
function (line 10 below) that will expose three helper methods:
-
dispatch(action) – which with take action
parameter which will look like this for example: {type: ‘CHANGE_USER_NAME’, data: ‘New User Name’ }
-
getState() – will return current state. This way we will protect the state from direct mutations. Only dispatching an actions could do this.
- subscribe(handler) – will be fired on component will mount and will pass a handler function that will react on state change, and call
setState
to update the local state and trigger UI update. The handler function for the Greetings component will look like this:
...
// this function is called when dispatch method is called and state is changed.
const newState = window.store.getState();
this.setState( {userName: newState.userName,
editMode: newState.editMode
});
...
this function will also return the unsubscribe function that we have to fire on componentWillUnmaunt
Let’s start with creating the store:
./src/store/index.js
const validateAction = action => {
if (!action || typeof action !== 'object' || Array.isArray(action)) {
throw new Error('Action must be an object!');
}
if (typeof action.type === 'undefined') {
throw new Error('Action must have a type!');
}
};
const createStore = reducer => {
let state;
const subscribers = [];
const store = {
dispatch: action => {
validateAction(action);
state = reducer(state, action);
subscribers.forEach(handler => handler());
},
getState: () => state,
subscribe: handler => {
subscribers.push(handler);
return () => {
const index = subscribers.indexOf(handler);
if (index !== -1) {
subscribers.splice(index, 1);
}
};
}
};
// Returns the initial state
store.dispatch({type: '--INIT--'});
return store;
};
export default createStore;
what we just did:
– like we described above, we created createStore
function that will return an object with dispatch, getState and subscribe functions (lines 16,22,24);
– dispatch will take an action and will pass it to the reducer, which will match the appropriate action type and will update the store (line 18).
Next it will call all subscribed functions, which will react on state update and will do setState
to trigger UI update.
– getState simply returns the new state and protects the state object from accidental mutation.
– subscribe function accepts handler function which will be invoked when the dispatch method was called to dispatch an action. It also returns another function to unsubscribe from the store and remove the handler from the subscribers array (line 26)
And now let’s do the reducer which simply will take the initial state, and then we will use the classic switch-case to match the action.type and return the new state. So right now only the reducers are able to update the state, making debugging much more predictable.
./src/reducers/index.js
const CHANGE_USERNAME = 'CHANGE_USERNAME';
const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
const initialState = {
userName: "No Name",
editMode: false
};
const reducer = (state = initialState, action) => {
switch (action.type) {
case CHANGE_USERNAME: {
let newState = {...state};
newState.userName = action.data;
newState.editMode = false;
return newState;
}
case TOGGLE_EDIT_MODE: {
let newState = {...state};
newState.editMode = !newState.editMode;
return newState;
}
default:
return state;
}
};
export default reducer;
Let’s instantiate the store and attach it to the window object so it will be accessible by the components:
./src/components/App/index.js
import React, { Component } from 'react';
import PageLayout from '../../containers/PageLayout';
import { ApolloProvider } from 'react-apollo';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import Store from '../../store';
import Reducers from '../../reducers';
import styles from './styles.scss';
window.store = Store(Reducers);
export default class App extends Component {
render() {
const GRAPHQL_URL = 'http://localhost:4001/graphql';
const client = new ApolloClient({
link: new HttpLink({ uri: GRAPHQL_URL }),
cache: new InMemoryCache()
});
return (
<div className={styles.appWrapper}>
<ApolloProvider client={client}>
<Router>
<Switch>
<Route exact path="*" component={PageLayout} />
</Switch>
</Router>
</ApolloProvider>
</div>
);
}
}
and now let’s use our new store in the Greeting component:
./src/components/Greetings/index.js
import React, { Component } from 'react';
const styles = require('./styles.scss');
const CHANGE_USERNAME = 'CHANGE_USERNAME';
const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
class Greetings extends Component {
constructor(props) {
super(props);
const store = window.store.getState();
let userName = store.userName;
let editMode = store.editMode;
this.state = {
userName: userName,
editMode: editMode
}
}
componentWillMount() {
this.unsubscribe = window.store.subscribe(() => {
// this function is called when dispatch method is called and state is changed.
const newState = window.store.getState();
this.setState( {userName: newState.userName,
editMode: newState.editMode
});
});
}
componentWillUnmount() {
this.unsubscribe();
}
doneEditUsername() {
let newName = document.querySelector('#inputField').value;
window.store.dispatch({type: CHANGE_USERNAME, data: newName });
}
usernameChanged(el) {
this.setState({ userName: el.target.value });
}
render() {
let element = <h2 onClick={() =>{ window.store.dispatch({type: TOGGLE_EDIT_MODE }); }}>Hello: {this.state.userName}</h2>;
if(this.state.editMode)
element = <h2>Type new name:<input type="text" id='inputField' value={this.state.userName} onChange={(el) => { this.usernameChanged(el);}} /><button onClick={() =>{ this.doneEditUsername() }}>done</button></h2>
return (
<div>
<div className={styles.wrapper}>
{element}
</div>
</div>);
}
}
export default Greetings;
Well done! Now it’s less messy, but the implementation still requires a lot of work to wire up the components, also we expose the store as a global variable which is a bit messy.
Create Provider and connect to pass properties using React Context.
We could fix this by using React’s Context.
Let’s explain this. Usually, we pass parameters from high order component to the components down below, but if we have to pass parameter from the highest component to the 4th component, we have to keep passing the property as props
to all components down to the 4th component. This is quite uncomfortable and tedious, but here is where Context comes handy. Context provides a way to pass data through the component tree without having to pass props down manually at every level.
Let’s do this! We will create:
- a high order component that will pass the store properties down using the Context. We will call it Provider.
- a Connect component, which will act like a wrapper component (or factory component) and will transform back the properties from the context and pass them to the wrapped component.
Creating provider component.
./src/containers/Provider/index.js
import React from 'react';
import PropTypes from 'prop-types';
class Provider extends React.Component {
getChildContext() {
return {
store: this.props.store
};
}
render() {
return this.props.children;
}
}
Provider.childContextTypes = {
store: PropTypes.object
};
export default Provider;
what we just did
– we created a high order component that will convert a store
properties into a context property.
– then the component will render the child components.
It’s pretty straight forward. Now we have the store properties passed into the context, and we will need a way to retrieve them back. For this purpose we will have to create a connect component.
Creating connect factory component.
./src/containers/Provider/connect.js
import React from 'react';
import PropTypes from 'prop-types';
const connect = (
mapStateToProps = () => ({}),
mapDispatchToProps = () => ({})
) => Component => {
class Connected extends React.Component {
onStoreOrPropsChange(props) {
const {store} = this.context;
const state = store.getState();
const stateProps = mapStateToProps(state, props);
const dispatchProps = mapDispatchToProps(store.dispatch, props);
this.setState({
...stateProps,
...dispatchProps
});
}
componentWillMount() {
const {store} = this.context;
this.onStoreOrPropsChange(this.props);
this.unsubscribe = store.subscribe(() =>
this.onStoreOrPropsChange(this.props)
);
}
componentWillReceiveProps(nextProps) {
this.onStoreOrPropsChange(nextProps);
}
componentWillUnmount() {
this.unsubscribe();
}
render() {
return <Component {...this.props} {...this.state}/>;
}
}
Connected.contextTypes = {
store: PropTypes.object
};
return Connected;
};
export default connect;
what we just did:
– we created a higher order component factory.
– It takes two functions and returns a function that takes a component and returns a new component, passing the store as prop value.
– the component retrieves back the store from the context (lines 11,12)
– then it calls mapStateToProps
and mapDispatchToProps
functions which lives in the actual component that we want to connect to the store. These functions does exactly when their names suggest. They map the properties from the store returning the state.
– next the setState is called with the updated state, and the view got updated.
Using the provider component.
This is as simply as wrapping the application with our Provider component.
./src/components/App/index.js
import React, { Component } from 'react';
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import { ApolloProvider, graphql } from 'react-apollo';
import { ApolloClient } from 'apollo-client';
import { HttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import PageLayout from '../../containers/PageLayout';
import Store from '../../store';
import Reducers from '../../reducers';
import Provider from '../../containers/Provider';
import fetch from 'unfetch';
let store = Store(Reducers);
export default class App extends Component {
render() {
const GRAPHQL_URL = 'http://localhost:4001/graphql';
const client = new ApolloClient({
link: new HttpLink({ uri: GRAPHQL_URL, fetch: fetch }),
cache: new InMemoryCache()
});
return (
<Provider store={store}>
<ApolloProvider client={client}>
<Router>
<Switch>
<Route exact path="*" component={PageLayout} />
</Switch>
</Router>
</ApolloProvider>
</Provider>
);
}
}
Now the store properties are converted to Context, and passed down.
Use Connect wrapper component.
Now in order to retrieve the store properties and use them, we wrap the components that should get these properties from the store with the newly created connect component, and clean up the code that we don’t need any more.
./src/components/Greetings/index.js
import React, { Component } from 'react';
import connect from '../../containers/Provider/connect';
const styles = require('./styles.scss');
const CHANGE_USERNAME = 'CHANGE_USERNAME';
const TOGGLE_EDIT_MODE = 'TOGGLE_EDIT_MODE';
class Greetings extends Component {
constructor(props) {
super(props);
}
doneEditUsername() {
let newName = document.querySelector('#inputField').value;
this.props.changeUserName(newName);
this.props.toggleLogInPopup();
}
usernameChanged(el) {
let newName = el.target.value;
this.props.changeUserName(newName);
}
onToggleEditMode() {
this.props.toggleLogInPopup();
}
render() {
let element = <h2 onClick={() =>{ this.onToggleEditMode() }}>Hello: {this.props.userName}</h2>;
if(this.props.editMode)
element = <h2>Type new name:<input type="text" id='inputField' value={this.props.userName} onChange={(el) => { this.usernameChanged(el);}} /><button onClick={() =>{ this.doneEditUsername() }}>done</button></h2>
return (
<div>
<div className={styles.wrapper}>
{element}
</div>
</div>);
}
}
const mapStateToProps = ( state ) => {
return {
userName: state.userName,
editMode: state.editMode
}
}
const mapDispatchToProps = dispatch => {
return {
toggleLogInPopup: () => {
dispatch({type: TOGGLE_EDIT_MODE});
},
changeUserName: (userName) => {
dispatch({type: CHANGE_USERNAME, data: userName});
}
}
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(Greetings);
what we just did:
– we wrapped Greetings component with the ‘connect’ component (lines 63-66) which now passes store properties to the Greetings component.
– we mapped the properties that we are interested in in mapStateToProps
so only these properties will be passed to the Greeting component (line 44).
– we mapped the functions that will dispatch actions in mapDispatchToProps
(line 51)
– we are calling changeUserName
and toggleLogInPopup
to dispatch the appropriate actions and update the state.
– we set up the input field to use this.props.userName
from the store (line 32) and also set up the ‘Hello’ display to use this.props.userName
Now everything is wired into nice automatic way, and this is in general how Redux works.
Add another store connected component and share the store.
Let’s also add another store connected component and see how both of them could use/edit the same store property. Let’s modify the About component to show/edit the userName property.
import React, { Component } from 'react';
import connect from '../../containers/Provider/connect.js';
const CHANGE_USERNAME = 'CHANGE_USERNAME';
class About extends Component {
constructor(props) {
super(props);
this.state = {
userName: this.props.userName,
};
}
handleChange() {
const userName = document.querySelector('input[name=username]').value;
this.setState( { userName: userName } );
this.props.changeUserName(userName);
}
render() {
return (
<div>
<p>This is <input type="text" name="username" value={this.state.userName} onChange={() => { this.handleChange()}} /></p>
</div>
);
}
}
//export default About;
const mapStateToProps = storeState => ({
userName: storeState.userName
}
);
const mapDispatchToProps = dispatch => {
return {
changeUserName: (userName) => {
dispatch({type: CHANGE_USERNAME, data: userName});
}
}
};
const AboutContainer = connect(
mapStateToProps,
mapDispatchToProps
)(About);
export default AboutContainer;
Pretty straight forward! Now no matter where you are going to edit the username both ‘About’ and ‘Greetings’ components will reflect the change.