Найти тему
Good SamarITan

Redux в React для чайников от чайника

Должен отметить что эта статья ни в коем случае не претендует на «best practice», и окажется для Вас полезной в том случае, если Вы, как и я в свое время, столкнулись с трудностью понимания принципов использования Redux в React.
P.S. В статье может быть целая куча пунктуационных ошибок, пусть они Вас не тревожат ;)

Я сам не являюсь frontend-разработчиком, но в своих побочных проектах я, как это часто бывает, и швец, и жнец, и на дуде игрец. Поэтому в той или иной степени пытаюсь и во фронт, хотя по большей степени предпочитаю пользоваться фреймворками, чтобы сэкономить время. Так например, я начал изучать React, и даже запилил mini-app «Вместе» для VK. Во время разработки я столкнулся с потребностью протаскивать состояние через длинную цепь компонентов, что иногда бывало затруднительно. И я задумался, а можно ли как-то менять состояние глобально?

Для этой цели как раз и существует Redux. Я не буду объяснять что это такое, если вы читаете это значит вы наверняка и сами уже сто раз прочли толковые статьи по типу этой. Тем не менее на всякий случай публикую наглядную схему:

Вот только примеры у всех этих статьей для меня оказались либо слишком сложными, либо слишком поверхностными. Я пытался сделать, казалось бы, простую вещь — по нажатию на кнопку на одном экране, глобально изменить текст на другом, который как раз берется из состояния приложения.

Говоря об экранах я имею ввиду React-Native , но в рамках статьи это не имеет никакого значения, принципы одни и те же. Вам не обязательно повторять за мной целиком, вы можете даже использовать совершенно другие компоненты и архитектуру приложения, главное обратите внимание на работу с Redux.

Итак, приступим. Предположим Вы уже создали проект реакт. Вот так выглядит мой основной компонент App:

import React from 'react'; // сам React
import {Provider} from 'react-redux'; // сам Redux
import { NavigationContainer } from '@react-navigation/native'; // React-Native
import { createDrawerNavigator } from '@react-navigation/drawer'; // Компонент навигации
import store from '../../store'; // объект хранилища, речь о создании которого будет ниже
/*Мои кастомные компоненты*/
import FirstScreen from '../firstscreen';
import SecondScreen from '../secondscreen';


const Drawer = createDrawerNavigator();
export default class App extends React.Component {
    render(){
        return(
            <Provider store={store}>
                <NavigationContainer>
                    <Drawer.Navigator initialRouteName="FirstScreen">
                        <Drawer.Screen name="FirstScreen" component={FirstScreen} />
                        <Drawer.Screen name="SecondScreen" component={SecondScreen} />
                    </Drawer.Navigator>       
                </NavigationContainer>
            </Provider>    
        ) ;
    }
}

Мое приложение состоит из 2х экранов — FirstScreen и SecondScreen. На обоих экранах есть кнопки, которые могут менять текст друг у друга. Для этого и используется Redux. Создадим директорию store в корне приложения, а в ней следующие папки и файлы:

-2

Что за что отвечает? Начнем с папки types. В ней хранятся пользовательские типы, передаваемые экшеном в редьюсером, на основе которых будут производится те или иные действия. То есть, это своеобразные флаги. Наверное это звучит сумбурно, но в дальнейшем будет понятней. Вот код единственного файла в папке:

/* store/types/index.js */

export const TEST_1 = "TEST_1";
export const TEST_2 = "TEST_2";

Все, как видите, ни для чего кроме как для хранения констант этот файл и не нужен. Идем дальше, в папку actions. Здесь хранятся действия, каждое из которых влияет на изменение глобального состояния приложения. Если Вы помните схему выше, то именно экшены дергают редьюсеры, которые уже непосредственно отражаются на состоянии.

/* store/actions/index.js */

import {TEST_1,TEST_2} from '../types';

export const change1stScreen = (text) =>  {
    console.log("Action 1: "+ text);
    return {
        type: TEST_1,
        payload: text
    }
}

export const change2ndScreen  = (text) =>  {
    console.log("Action 2: "+ text);
    return {
        type: TEST_2,
        payload: text
    }
}

Что мы видим — у нас есть 2 действия, каждое из которых принимает на вход строку, и возвращает объект с 2мя свойствами: type и payload . Что такое type и так ясно а payload это и есть полезная информация, в нашем случае текст на который будет произведена замена при нажатии кнопки. Потому переходим в папку reducers, и смотрим что там.

1й редьюсер:

/* store/reducers/test1.js */

import {TEST_1} from '../types';

const INITIAL_STATE = {
     text1: "default text 1"
};

export default (state = INITIAL_STATE, action) => {
    switch (action.type) {
        case TEST_1:
            return {
                ...state, text1: action.payload // обновляем текст
            }
            default:
                return state; // если тип не пришел, тогда просто возвращаем INITIAL_STATE
    }
}

2й редьюсер:

/* store/reducers/test2.js */

import {TEST_2} from '../types';

const INITIAL_STATE = {
    text2: "default text 2"
};

export default (state = INITIAL_STATE, action) => {
    switch (action.type) {
        case TEST_2:
            return {
                ...state, text2: action.payload // обновляем текст
            }
            default:
                return state; // если тип не пришел, тогда просто возвращаем INITIAL_STATE
    }
}

В моем примере оба редьюсера почти идентичны, само собой я сделал это намеренно, чтобы было понятней. Что тут происходит — мы создаем дефолтное состояние (INITIAL_STATE), далее экспортируем лямбда функцию, которая в зависимости от типа обновляет состояние на пришедший payload. Как видите тип позволяет сделать процесс работы с редьюсером более гибким, и например обновлять несколько свойств состояния за раз (в моем примере это не рассматривается, но это не должно вызывать какие либо сложности). По дефолту мы возвращаем INITIAL_STATE.

В индексном файле мы объединяем все наши редьюсеры в 1. Это удобнее чем создавать один огромный редьюсер.

/* store/reducers/index.js */

import {combineReducers} from 'redux';
import test1 from './test1';
import test2 from './test2';

export default combineReducers({
    testReducer1: test1,
    testReducer2: test2
});

Ну и наконец создаем хранилище в файле index.js папки store:

/* store/index.js */

import {createStore} from 'redux';
import reducers from './reducers';

const store = createStore(reducers);

export default store;

Дело осталось за малым, применить все вышеописанное в наших экранах FirstScreen и SecondScreen

// firstscreen.js

import  React from 'react';
import {
    View,
    Text,
    Button
  } from 'react-native';
import { connect } from 'react-redux';
import { change2ndScreen } from '../../store/actions';
import { bindActionCreators } from 'redux';



class FirstScreen extends React.Component {
    constructor(props){
        super(props);
    }

    render(){
        let {text1} = this.props;
        return( 
            <View>
                <Text>Text 1st Screen: {text1}</Text>
                <Button onPress={() => this.props. change2ndScreen("new text 2")}  title="change 2nd Screen Text"></Button>
            </View>
        );
    }
}

const mapStateToProps = state => ({ 
    text1:  state.testReducer1.text1
  });

const mapDispatchToProps = dispatch => ({
         change2ndScreen: bindActionCreators(change2ndScreen, dispatch)
 });


  export default connect(
    mapStateToProps,
    mapDispatchToProps
  )(FirstScreen);

Объясняю по порядку — компонент FirstScreen выступает в роли первого экрана. Он содержит текст со строкой содержащей строку из сформированного нами ранее хранилища (store) и кнопку, изменяющую текст второго экрана с помощью экшена change2ndScreen. Свойство text1 было у первого редьюсера. А получаем мы всю эту канитель с помощью mapStateToProps . Точно также мы получаем и необходимые нам экшены, но уже через mapDispatchToProps (их мы вынуждены импортировать вручную, прежде чем привязать). Далее мы привязываем через connect() полученные от редьюсера свойства и экшены к объекту props а не в state компонента. Чаще всего когда говорят о Redux фигурирует слово «состояние», что может вызвать путаницу.

Если у Вас возник вопрос а как redux получает доступ к store в этом компоненте, то обратите внимание на Provider в примере кода главного компонента App. В Provider обернуто все приложение. Это и обеспечивает связь низкоуровневых компонентов с хранилищем через Redux.

По той же схеме сделан и компонент SecondScreen

// secondscreen.js

import  React from 'react';
import {
    View,
    Text,
    Button
  } from 'react-native';
import { connect } from 'react-redux';
import { change1stScreen } from '../../store/actions';
import { bindActionCreators } from 'redux';



class SecondScreen extends React.Component {
    constructor(props){
        super(props);
    }

    render(){
        let {text2} = this.props;
        return( 
            <View>
                <Text>Text 2nd Screen: {text2}</Text>
                <Button onPress={() => this.props.change1stScreen("new text 1")}  title="change 1st Screen Text"></Button>
            </View>
        );
    }
}

const mapStateToProps = state => ({ 
    text2:  state.testReducer2.text2
  });

const mapDispatchToProps = dispatch => ({
         change1stScreen : bindActionCreators(change1stScreen, dispatch)
 });


  export default connect(
    mapStateToProps,
    mapDispatchToProps
  )(SecondScreen);

Вот и все. Надеюсь что у Вас получилось вникнуть в то как использовать Redux в React на моем ( худо-бедно, но все же) примере. Попробуйте воспроизвести и убедиться в его работоспособности. Или поупражняйтесь чтобы закрепить полученные знания.