import React from "react"
import AppBar from "@material-ui/core/AppBar/AppBar";
import Button from '@material-ui/core/Button';
import {Link, Route} from "react-router-dom";
import {About} from "./about/about";
import ConnectedNews from "./news/news";
import Toolbar from "@material-ui/core/Toolbar/Toolbar";
import Typography from "@material-ui/core/Typography/Typography";
export const Layout = ({match}) => (
<main>
<AppBar position="static">
<Toolbar>
<Typography variant="title" color="inherit" style={{flexGrow: 1}}>
Project-example
</Typography>
<Button variant="contained" color="secondary" component={Link} to="/about">
О проекте
</Button>
<Button variant="contained" color="secondary" component={Link} to="/news">
Новости
</Button>
</Toolbar>
</AppBar>
<Route path={`${match.url}about`} component={About}/>
<Route path={`${match.url}news`} component={ConnectedNews}/>
</main>
)
const App = () => (
<Provider store={store}>
<Router>
<Route path="/" component={Layout} />
</Router>
</Provider>
)
npm i redux-devtools-extension
import { createStore } from 'redux'
import { composeWithDevTools } from 'redux-devtools-extension';
import {reducer} from "./reducer";
export const store = createStore(reducer, composeWithDevTools());
Download browser extension: https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=ru
export const TYPES = {
SELECT_ARTICLE: 'SELECT_ARTICLE'
};
New action creator for news fetch:
export const loadNews = () => {
return {
type: TYPES.LOAD_NEWS
}
};
Connect it to our News component:
const mapDispatchToProps = (dispatch) => ({
selectArticle: (articleText) => dispatch(selectArticle(articleText)),
loadNews: () => dispatch(loadNews())
});
What's now? How to make an async request within redux and save receided data in store?
We could achieve this in our reducer or in action creator function.
Redux ideology tells us that reducer is a pure function with no side effects. So let's try to implement asynchronous request in our loadNews
action.
At first our action creators was just a functions that receives some parameters and returns action
object with type
key and payload for our reducer with dispatch
method in our components.
Now we need to upgrade our action with inversion of control pattern - we need to put dispatch
method in our action to make possible firing actions at any moment we need (asynchronously).
// old mapDispatchToProps:
loadNews: () => dispatch(loadNews())
// new mapDispatchToProps:
loadNews: () => loadNews(dispatch)
Now we have a dispatch
method inside our action creator:
// We can call more nested actions from our initial action with dispatch
export const loadNews = (dispatch) => {
console.log('Dispatch: ', dispatch);
return {
type: TYPES.LOAD_NEWS
}
};
Let's move our AJAX request into action creator:
export const loadNews = (dispatch) => {
axios.get('https://meduza.io/api/v3/search?chrono=news&locale=ru&page=0&per_page=24')
.then((response) => {
// Dispatching an action only when request complete
dispatch({
type: TYPES.LOAD_NEWS,
data: response.data
})
})
.catch((e) => {
});
};
We have no access to setState
method anymore (we are outside of any Component). But now we can use dispatch
to pass our data to redux store:
const initialState = {
article: void 0,
news: []
};
export const reducer = (prevState = initialState, action) => {
const newState = {...prevState};
switch (action.type) {
case TYPES.SELECT_ARTICLE:
return {...newState, article: action.article};
case TYPES.LOAD_NEWS:
// reacting to data fetch and saving data in store
return {...newState, news: Object.values(action.data.documents)}
}
return newState
};
Connect our new store data to News
component:
class News extends React.Component {
...
render() {
return (
<ul>
{this.props.news.map((doc) => (
<li key={doc.title}>
<h3 onClick={ () => this.props.selectArticle(doc.title)}>{doc.title}</h3>
</li>
))}
</ul>
)
}
}
const mapStateToProps = (state) => ({
selectedArticle: state.article,
news: state.news
});
const mapDispatchToProps = (dispatch) => ({
selectArticle: (articleText) => dispatch(selectArticle(articleText)),
loadNews: () => loadNews(dispatch)
});
const ConnectedNews = connect(mapStateToProps, mapDispatchToProps)(News);
Like with ajax call inside our News component handlers we have to keep in mind about all application states during async request.
Action creator:
export const loadNews = (dispatch) => {
dispatch({type: TYPES.LOAD_NEWS_STARTED});
axios.get('https://meduza.io/api/v3/search?chrono=news&locale=ru&page=0&per_page=24')
.then((response) => {
// Dispatching an action only when request complete
dispatch({
type: TYPES.LOAD_NEWS,
data: response.data
})
})
.catch((e) => {
dispatch({type: TYPES.LOAD_NEWS_FAILED, error: e});
});
};
Reducer:
const initialState = {
article: void 0,
news: [],
newsIsLoading: false,
newsLoadingFailed: false
};
export const reducer = (prevState = initialState, action) => {
const newState = {...prevState};
switch (action.type) {
case TYPES.SELECT_ARTICLE:
return {...newState, article: action.article};
case TYPES.LOAD_NEWS_STARTED:
return {...newState,
newsIsLoading: true,
newsLoadingFailed: false
};
case TYPES.LOAD_NEWS:
return {...newState,
news: Object.values(action.data.documents),
newsIsLoading: false,
newsLoadingFailed: false
};
case TYPES.LOAD_NEWS_FAILED:
return {...newState,
newsIsLoading: false,
newsLoadingFailed: true
};
}
return newState
};
News
component:
class News extends React.Component {
onClick = () => {
this.props.loadNews();
};
render() {
return (
<React.Fragment>
<h2>{this.props.selectedArticle}</h2>
<Button onClick={this.onClick} variant="contained" color="primary">
Загрузить новости
</Button>
{this.props.newsIsLoading && <div>Подождите, идет загрузка</div>}
{this.props.newsLoadingFailed && <div>Ой-ой :(</div>}
<ul>
{this.props.news.map((doc) => (
<li key={doc.title}>
<h3 onClick={ () => this.props.selectArticle(doc.title)}>{doc.title}</h3>
</li>
))}
</ul>
</React.Fragment>
)
}
}
const mapStateToProps = (state) => ({
selectedArticle: state.article,
news: state.news,
newsIsLoading: state.newsIsLoading,
newsLoadingFailed: state.newsLoadingFailed
});
const mapDispatchToProps = (dispatch) => ({
selectArticle: (articleText) => dispatch(selectArticle(articleText)),
loadNews: () => loadNews(dispatch)
});
const ConnectedNews = connect(mapStateToProps, mapDispatchToProps)(News);
Let's add some user input in our application:
export class Search extends React.Component {
state = {
page: 0,
articlesPerPage: 24
};
handleChange = name => event => {
this.setState({
[name]: event.target.value,
});
};
render() {
return (
<form>
<TextField
label="Страница"
value={this.state.page}
onChange={this.handleChange('page')}
margin="normal"
/>
<TextField
label="Новостей на странице"
value={this.state.articlesPerPage}
onChange={this.handleChange('articlesPerPage')}
margin="normal"
/>
</form>
);
}
}
Now we have to connect our form values with ajax request and pass field values to it as a GET params.
Initial API request:
https://meduza.io/api/v3/search?chrono=news&locale=ru&page=0&per_page=24
To make form-to-store mapping easier we could use a redux-form
library:
npm i redux-form
<form>
<Field component={TextField} name="page" label="Страница" margin="normal"/>
<Field component={TextField} name="articlesPerPage" label="Новостей на странице" margin="normal"/>
</form>
const initialValues = {
page: 0,
articlesPerPage: 24
};
export default reduxForm({initialValues})(Search);
Now form is rendered, but no initial values in fields and no binding with store. What's whrong?
Problem is: Redux-form's <Field/>
sends to out component values and handlers as a special props. We must utilize this props in our component, but components from material-ui
have different API, so we need to make an adapter to transform reux-form API props to material-ui format.
Here is a HOC for this:
const AdaptedTextField = ({input: {value, onChange}, ...custom}) => (
<TextField
value={value}
onChange={onChange}
{...custom}
/>
);
Redux-form's <Field />
passing two high-level props to our component: imput
and meta
. Input contains data and handlers for two-way field manipulations like set value of the field with input.value
and handling field user input with input.onChange
handler. meta
object contains some field internal state flags such as touched
, active
and etc.
API on redux-form's Field
could be found here: https://redux-form.com/7.4.2/docs/api/field.md/
After our modifications we could see that when we typing letters in our inputs, redux dev tools shows us actions, firing by redux-form
(like @@redux-form/CHANGE
. But out application store still without changes.
That's because we forgot one last step: to connect redux-rorm's reducers to our store. Let's do this:
import {createStore, combineReducers} from 'redux'
import {composeWithDevTools} from 'redux-devtools-extension';
import {reducer as formReducer} from 'redux-form'
import {reducer as appReducer} from "./reducer";
const reducer = combineReducers({
app: appReducer,
form: formReducer
});
export const store = createStore(reducer, composeWithDevTools());
Finally - fully functional form with store connection!
Now we can use our stored data to inject params into out AJAX request url:
export const loadNews = (dispatch) => {
dispatch({type: TYPES.LOAD_NEWS_STARTED});
/* Get page and articlesPerPage from store somehow */
axios.get('https://meduza.io/api/v3/search?chrono=news&locale=ru&page=0&per_page=24')
...
Our action creator became more and more complicated. To make things a little bit more easier let's take a look at redux-thunk
lib: https://github.com/reduxjs/redux-thunk
Simplest action creator:
function selectArticle() {
return {
type: SELECT_ARTICLE,
article
};
}
An action creator with dispatch as argument (naive approach):
export const loadNews = (dispatch) => {
dispatch({type: TYPES.LOAD_NEWS_STARTED});
/* some async operations */
}
An action creator with use of thunk
:
export const loadNews = (params) => (dispatch, getState) => {
dispatch({type: TYPES.LOAD_NEWS_STARTED});
/* some async operations with function params and access to store with getState */
}
npm i redux-thunk
import {createStore, combineReducers, applyMiddleware} from 'redux'
import thunk from 'redux-thunk'
import {composeWithDevTools} from 'redux-devtools-extension';
// ...
export const store = createStore(reducer, composeWithDevTools(applyMiddleware(thunk)));
// in mapDispatchToProps
loadNews: () => dispatch(loadNews())
export const loadNews = () => (dispatch, getState) => {
dispatch({type: TYPES.LOAD_NEWS_STARTED});
const formData = getState().form.search.values;
console.log(formData);
const { page, articlesPerPage } = formData;
axios.get(`https://meduza.io/api/v3/search?chrono=news&locale=ru&page=${page}&per_page=${articlesPerPage}`)
.then((response) => {
// Dispatching an action only when request complete
dispatch({
type: TYPES.LOAD_NEWS,
data: response.data
})
})
.catch((e) => {
dispatch({type: TYPES.LOAD_NEWS_FAILED, error: e});
});
};