I've been looking into creating generic modals with React, Redux, and Thunk. Ideally, my state would look like the following:
export interface ConfirmModalState {
isOpened: boolean;
onConfirm: null | Function
}
export const initialConfirmModalState: ConfirmModalState = {
isOpened: false,
onConfirm: null
};
However, this would mean putting non-serializable data into the state, which seems to be highly discouraged.
I've read a great blogpost by markerikson. However, I don't think the proposed solution would work with asynchronous actions and Thunk.
How do you suggest to resolve this issue?
I actually wrote the post that you linked, and I wrote a much-expanded version of that post a couple years later:
Practical Redux, Part 10: Managing Modals and Context Menus.
I've actually implemented a couple variations of this approach myself since I wrote that post, and the best solution I've found is to add a custom middleware that returns a promise when you dispatch a "show modal" action, and resolves the promise with a "return value" when the dialog is closed.
There's an existing implementation of this approach at https://github.com/AKolodeev/redux-promising-modals . I ended up making my own implementation. I have a partial version of my homegrown approach in a gist at https://gist.github.com/markerikson/8cd881db21a7d2a2011de9e317007580 , and the middleware looked roughly like:
export const dialogPromiseMiddleware: Middleware<DialogPromiseDispatch> = storeAPI => {
const dialogPromiseResolvers: Record<string, Resolver> = {};
return next => (action: AnyAction) => {
switch (action.type) {
// Had to resort to `toString()` here due to https://github.com/reduxjs/redux-starter-kit/issues/157
case showDialogInternal.toString(): {
next(action);
let promiseResolve: Resolver;
const dialogPromise = new Promise((resolve: Resolver) => {
promiseResolve = resolve;
});
dialogPromiseResolvers[action.payload.id] = promiseResolve!;
return dialogPromise;
}
case closeDialog.toString(): {
next(action);
const {id, values} = action.payload;
const resolver = dialogPromiseResolvers[id];
if (resolver) {
resolver(values);
}
delete dialogPromiseResolvers[id];
break;
}
default:
return next(action);
}
};
};
(note: I made that gist when I was having some TS syntax issues getting dispatching to work correctly, so it's likely it won't 100% work out of the box. RTK also now includes some .match() action matching utilities that would be useful here. but, it shows the basic approach.)
The rough usage in a component is:
const closedPromise = dispatch(showDialog("TestDialog", {dialogNumber : counter});
const result = await closedPromise
// do something with the result
That way you can write the "on confirm" logic write there in the place that asked for the dialog to be shown in the first place.
Thank you markerikson for providing an answer. This inspired me to create a solution with thunks. Please give me some feedback here :)
I will be using hooks and #reduxjs/toolkit in my example.
This is the state of my ConfirmationModal reducer:
export interface confirmationModalState {
isOpened: boolean;
isConfirmed: boolean;
isCancelled: boolean;
}
export const initialConfirmationModalState: confirmationModalState = {
isOpened: false,
isConfirmed: false,
isCancelled: false,
};
This is the slice (a combination of the reducer and actions):
import { createSlice } from '#reduxjs/toolkit';
import { initialConfirmationModalState } from './state';
const confirmationModalSlice = createSlice({
name: 'controls/confirmationModal',
initialState: initialConfirmationModalState,
reducers: {
open: state => {
state.isOpened = true;
state.isConfirmed = false;
state.isCancelled = false;
},
confirm: state => {
state.isConfirmed = true;
state.isOpened = false;
},
cancel: state => {
state.isCancelled = true;
state.isOpened = false;
},
},
});
export const confirmationModalActions = confirmationModalSlice.actions;
export default confirmationModalSlice;
This is the thunk action for it:
import { createAsyncThunk } from '#reduxjs/toolkit';
import ThunkApiConfig from '../../../types/ThunkApiConfig';
import { AppState } from '../../reducers';
import { confirmationModalActions } from './slice';
const confirmationModalThunkActions = {
open: createAsyncThunk<boolean, void, ThunkApiConfig>(
'controls/confirmationModal',
async (_, { extra, dispatch }) => {
const store = extra.store;
dispatch(confirmationModalActions.open());
return await new Promise<boolean>(resolve => {
store.subscribe(() => {
const state: AppState = store.getState();
if (state.controls.confirmationModal.isConfirmed) {
resolve(true);
}
if (state.controls.confirmationModal.isCancelled) {
resolve(false);
}
});
});
},
),
};
export default confirmationModalThunkActions;
You can notice it uses extra.store to perform the subscribe. We need to provide it when creating a store:
import combinedReducers from './reducers';
import { configureStore, getDefaultMiddleware } from '#reduxjs/toolkit';
import { ThunkExtraArguments } from '../types/ThunkExtraArguments';
function createStore() {
const thunkExtraArguments = {} as ThunkExtraArguments;
const customizedMiddleware = getDefaultMiddleware({
thunk: {
extraArgument: thunkExtraArguments,
},
});
const store = configureStore({
reducer: combinedReducers,
middleware: customizedMiddleware,
});
thunkExtraArguments.store = store;
return store;
}
export default createStore();
Now, let's create a hook that allows us to dispatch all of the above actions:
import { useDispatch, useSelector } from 'react-redux';
import { AppState } from '../../../reducers';
import { useCallback } from 'react';
import confirmationModalThunkActions from '../thunk';
import { confirmationModalActions } from '../slice';
import { AppDispatch } from '../../../../index';
export function useConfirmationModalState() {
const dispatch: AppDispatch = useDispatch();
const { isOpened } = useSelector((state: AppState) => ({
isOpened: state.controls.confirmationModal.isOpened,
}));
const open = useCallback(() => {
return dispatch(confirmationModalThunkActions.open());
}, [dispatch]);
const confirm = useCallback(() => {
dispatch(confirmationModalActions.confirm());
}, [dispatch]);
const cancel = useCallback(() => {
dispatch(confirmationModalActions.cancel());
}, [dispatch]);
return {
open,
confirm,
cancel,
isOpened,
};
}
(don't forget to attach confirm and cancel to the buttons in your modal)
And that's it! We can now dispatch our confirmation modal:
export function usePostControls() {
const { deleteCurrentPost } = usePostsManagement();
const { open } = useConfirmationModalState();
const handleDelete = async () => {
const { payload: isConfirmed } = await open();
if (isConfirmed) {
deleteCurrentPost();
}
};
return {
handleDelete,
};
}
Related
I'm trying to convert an existing project to configureStore from createStore.
store.js :
export default configureStore({
reducer: {
articlesList: loadArticlesReducer
}
});
home.js :
const articlesList = useSelector((state) => state.articlesList);
const dispatch = useDispatch()
useEffect(() => {
dispatch(getArticles());
})
articleSlice.js :
const articleSlice = createSlice({
name: 'articles',
initialState : [],
reducers : {
loadArticlesReducer: (state, action) => {
console.log("WE NEVER REACH THIS CODE") <=== the problem is here
state = action.payload;
}
}
});
export const { loadArticlesReducer } = articleSlice.actions;
export const getArticles = () => dispatch => {
fetch("https://....")
.then(response => response.json())
.then(data => {
dispatch(loadArticlesReducer(data))
})
};
The problem, as stated in the comment, is that getArticles action never dispatches the data to loadArticlesReducer.
What am I missing here?
loadArticlesReducer is an action creator, not a reducer function. I suggest renaming the action creator so its purpose isn't confusing to future readers (including yourself) and actually exporting the reducer function.
Example:
const articleSlice = createSlice({
name: 'articles',
initialState : [],
reducers : {
loadArticlesSuccess: (state, action) => {
state = action.payload;
}
}
});
export const { loadArticlesSuccess } = articleSlice.actions;
export const getArticles = () => dispatch => {
fetch("https://....")
.then(response => response.json())
.then(data => {
dispatch(loadArticlesSuccess(data));
});
};
export default articleSlice.reducer; // <-- export reducer function
import articlesReducer from '../path/to/articles.slice';
export default configureStore({
reducer: {
articlesList: articlesReducer
}
});
You may also want to consider converting getArticles to a more idiomatic RTK thunk function using createAsyncThunk. You'd use the slice's extraReducers to handle the fulfilled Promise returned from the Thunk. Example:
import { createAsyncThunk, createSlice } from '#reduxjs/toolkit';
export const getArticles = createAsyncThunk(
"articles/getArticles",
() => {
return fetch("https://....")
.then(response => response.json());
}
);
const articleSlice = createSlice({
name: 'articles',
initialState : [],
extraReducers: builder => {
builder.addCase(getArticles.fulfilled, (state, action) => {
state = action.payload;
});
},
});
export default articleSlice.reducer;
As per the documentation, you'll need to replace the following line:
export default configureStore({
reducer: {
articlesList: loadArticlesReducer // <= This currently points to the exported actions and not the reducer
}
});
With this one:
import articleReducer from './articleSlice.js'
export default configureStore({
reducer: {
articlesList: articleReducer
}
});
You'll need to export the reducer from the articleSlice.js of course:
export default articleSlice.reducer;
As a general tip, always reproduce the examples from the documentation using exactly the same setup and naming, and once it works, customize the code accordingly in a slow step by step manner. It's easy to miss something while replicating such complicated setups if there's no 1-to-1 correspendence with the original code.
I'm dispatching an action inside the component file to get filtered data from the Api and I want to get that filtered data with useSelector() but it's not working
Component.js
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { apiRequestFilteredCourses } from '../../../actions';
const Courses = () => {
const categories = useSelector(state => state.categories);
const dispatch = useDispatch();
if (categories.isLoading || courses.isLoading) {
return (<h1>Loading</h1>);
}
const query = { category_id: 1 };
dispatch(apiRequestFilteredCourses(query));
filteredCoursesActions.js
import {
API_REQUEST_FILTERED_COURSES,
API_RECEIVE_FILTERED_COURSES,
} from '../constants/actions';
export const apiRequestFilteredCourses = data => (
{
type: API_REQUEST_FILTERED_COURSES,
payload: data,
}
);
export const apiReceiveFilteredCourses = data => ({ data, type: API_RECEIVE_FILTERED_COURSES });
filteredCoursesSagas.js
import { put, takeEvery } from 'redux-saga/effects';
import { apiReceiveFilteredCourses, apiRequestFilteredCourses } from '../actions';
import {
API_REQUEST_FILTERED_COURSES,
} from '../constants/actions';
import Api from '../services/Api';
// eslint-disable-next-line no-unused-vars
function* filterCourses(action) {
const query = new URLSearchParams(action.payload).toString();
try {
const { data, status } = yield Api.get(`courses?${query}`, false);
if (status === 200) {
yield put(apiReceiveFilteredCourses(data.data));
} else {
console.log(`Action with constant ${API_REQUEST_FILTERED_COURSES} Failed with status code ${status}`);
}
} catch (e) {
console.log('FROM COURSES', e);
}
}
export default function* filteredCoursesSagas() {
yield takeEvery(API_REQUEST_FILTERED_COURSES, filterCourses);
yield put(apiRequestFilteredCourses());
}
filteredCoursesReducer.js
import {
API_RECEIVE_FILTERED_COURSES, API_REQUEST_FILTERED_COURSES,
} from '../constants/actions';
const defaultState = {
isLoading: false,
filteredCourses: [],
isLoaded: false,
};
const filteredCoursesReducer = (state = defaultState, { type, data }) => {
switch (type) {
case API_RECEIVE_FILTERED_COURSES:
return {
...state,
filteredCourses: data,
isLoaded: true,
isLoading: false,
};
case API_REQUEST_FILTERED_COURSES:
return {
...state,
isLoading: true,
isLoaded: false,
filteredCourses: [],
};
default:
return state;
}
};
export default filteredCoursesReducer;
Api.js
static async get(url, throwErrors = false) {
const axiosConf = this.getConf();
try {
const response = await axios.get(url, axiosConf);
if (throwErrors && (response.status <= 100 || response.status >= 400)) {
throw new Error(`Art API Returned error status ${response.status}`);
}
return response;
} catch (e) {
Api.showError(e);
return e;
}
}
I tried to get the filteredCourses like I did with the categories which is working normally but it completely bugged the app.
const filteredCourses = useSelector(state => state.filteredCourses)
Your reducer does not seem to be handling API_REQUEST_FILTERED_COURSES, so of course, nothing will happen with your state.
Generally, just so you are aware: you are using an extremely outdated style of Redux here. Since 2019, we recommend using the official Redux Toolkit, which does not have ACTION_TYPES, switch..case reducers, hand-written action creators or immutable reducer logic with spread anymore. It is only 1/4 of the code. We also do not recommend using Redux-Saga anymore for most use cases - instead, you should be using RTK Query, createAsyncThunk, thunks, or the listenerMiddlware.
I would highly recommend you to read Why Redux Toolkit is how to use Redux today and then to take the official Redux tutorial. That old style of Redux you are using here is a pain and there is really no good reason to use that nowadays.
im new to the redux toolkit here is my problem
im fetching data every time by creating own custom useFetch file for handling loading , success, error status
i used the same way with redux toolkit by creating createSlice method for accesing reducers and actions
this is working successfully and getting data from this way by dispaching actions and reducers
but i did't used the createAsyncThunk from redux toolkit
my confusion is is this currect way to fetch data from custom useFetch or should i use createAsyncthunk
Im not sure how to use createAsyncThunk in custom useFetch
if anyone knows the answer that is so appreciatable
posted my all files below
if i get answer with createAsyncThunk in custom useFetch that is soo appreciable
thanks advance
App.js
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import useFetchData from "./Components/useFetchData";
import { actions } from "./Slices/CounterSlice";
const App = () => {
useFetchData("https://jsonplaceholder.typicode.com/todos");
const apiData = useSelector((s) => s);
console.log(apiData.api);
return (
<>
{apiData.api.status !== "success" && <h1>hello</h1>}
{apiData.api.status === "success" &&
apiData.api.apiData.map((el) => {
return <h6 key={el.id}>{el.title}</h6>;
})}
</>
);
};
export default App;
custom useFetchData.js file
import React, { useCallback } from "react";
import { useDispatch } from "react-redux";
import { ApiActions } from "../Slices/ApiSlice";
const useFetchData = (link) => {
const dispatch = useDispatch();
const fetchData = useCallback(async () => {
try {
dispatch(ApiActions.loadingTime());
const getData = await fetch(link);
const toJson = await getData.json();
dispatch(ApiActions.successTime(toJson));
} catch {
dispatch(ApiActions.errorTime());
}
}, [link]);
React.useEffect(() => {
fetchData();
}, [fetchData]);
};
export default useFetchData;
this is createSlice file for creating actions and reducers
import { createSlice } from "#reduxjs/toolkit";
const apiSlice = createSlice({
name: "api",
initialState: {
status: "idle",
apiData: [],
error: false,
},
reducers: {
loadingTime: (state, action) => {
state.status = "Loading";
},
successTime: (state, action) => {
state.apiData = action.payload;
state.status = "success";
},
errorTime: (state, action) => {
state.apiData = [];
state.status = "error";
},
},
});
export const ApiActions = apiSlice.actions;
export const apiReducers = apiSlice.reducer;
You can use one way or the other. Example with createAsyncThunk:
export const checkIfAuthenticated = createAsyncThunk(
"auth/checkIsAuth",
async (_, thunkAPI) => {
const response = await axios.post(
"http://127.0.0.1:8080/dj-rest-auth/token/verify/",
{ token: localStorage.getItem("access") }
);
thunkAPI.dispatch(getUserInfo());
return response.data;
}
);
Then you handle loading, success, error status as before:
extraReducers: (builder) => {
builder
.addCase(checkIfAuthenticated.fulfilled, (state, action) => {
state.isAuthenticated = true;
})
.addCase(checkIfAuthenticated.rejected, (state, action) => {
state.isAuthenticated = false;
snackbar({ error: "Nie jesteÅ› zalogowany" });
})
},
However, if you have a lot of API queries, the optimal solution is to use redux toolkit query or react-query. These two libraries make queries a lot better.
If you care about code cleanliness and ease, check them out.
I want to use redux hook useSelector to access the store and get rid of connect(), so I need to create a way to export my actions, and I'm thinking on a class with static methods, here is an example
export default class AuthActions {
static async login(userData) {
try {
const user = await axios.post('http://localhost:5000', userData);
dispatch({
type: AUTH.LOGIN,
payload: user.data
})
} catch (error) {
dispatch({
type: SET_ERROR,
payload: error
})
}
}
static setUser() {
console.log("SET USER")
}
static logout() {
console.log("Logout")
}
}
And then I use the action methods as follows:
import React from 'react';
import AuthActions from '../../redux/actions/AuthActions';
import { useSelector } from 'react-redux';
export default const Login = () => {
//More logic....
const { isAuth } = useSelector((state) => state.auth);
const submitHandler = e => {
e.preventDefault();
AuthActions.login(userData)
}
return (
<form onSubmit={submitHandler}>
My Login form ....
</form>
);
};
But I'm wondering if there is a disadvantage or performance issues to use redux in this way, or should I avoid the usage of a class and use a simple object instead?
Thank you in advance
this is my format of a reducer, inspired by ducks-modular-redux
for example, check out this darkMode reducer:
export const constants = {
TOGGLE: "darkMode/TOGGLE"
};
export const actions = {
toggleDarkMode: () => {
return {
type: constants.TOGGLE
};
}
};
export const thunks = {
toggleDarkMode: () => {
return (dispatch, getState) => {
dispatch(actions.toggleDarkMode());
const isDark = getState().darkMode.isDark;
localStorage.setItem("isDark", isDark);
};
}
};
const initialState = { isDark: localStorage.getItem("isDark") === "true" };
export default (state = initialState, action) => {
switch (action.type) {
case constants.TOGGLE:
return {
isDark: !state.isDark
};
default:
return state;
}
};
I wonder if React Native has a bug that needs fixing that gives the following error:
React Native: TypeError: undefined is not an object (evaluating
'_this.props.data.map')
I am pretty good at this and yet I cannot seem to resolve why I am getting this error when I put together this component:
import React, { Component } from "react";
import { View, Animated } from "react-native";
class Swipe extends Component {
renderCards() {
return this.props.data.map(item => {
return this.props.renderCard(item);
});
}
render() {
return <View>{this.renderCards()}</View>;
}
}
export default Swipe;
I have checked and double checked through various debugging practices that the problem is not with my action creator or reducer and after various refactors I got those working correctly.
I decided to do the above component from scratch whereas before I was reusing another component and yet I still get the above error.
I ask if it's a bug with RN because someone else posted a similar problem but they did not get the answer they needed.
It is not a scope issue with this because if I refactor it like so:
renderCards = () => {
return this.props.data.map(item => {
return this.props.renderCard(item);
});
};
It does absolutely nothing for me, same error message. The message saying is not an object is confusing too, it's an array and map() can only iterate through arrays, so not sure what not being an object has to do with it.
The above component is being called in this screen:
import React, { Component } from "react";
import { View, Text } from "react-native";
import { connect } from "react-redux";
import Swipe from "../components/Swipe";
class DeckScreen extends Component {
renderCard(job) {
return (
<Card title={job.title}>
<View style={styles.detailWrapper}>
<Text>{job.company}</Text>
<Text>{job.post_date}</Text>
</View>
<Text>
{job.description.replace(/<span>/g, "").replace(/<\/span>/g, "")}
</Text>
</Card>
);
}
render() {
return (
<View>
<Swipe data={this.props.jobs} renderCard={this.renderCard} />
</View>
);
}
}
const styles = {
detailWrapper: {
flexDirection: "row",
justifyContent: "space-around",
marginBottom: 10
}
};
function mapStateToProps({ jobs }) {
return { jobs: jobs.listing };
}
export default connect(mapStateToProps)(DeckScreen);
This is what the action creator looks like:
import axios from "axios";
// import { Location } from "expo";
import qs from "qs";
import { FETCH_JOBS, LIKE_JOB } from "./types";
// import locationify from "../tools/locationify";
const JOB_ROOT_URL = "https://authenticjobs.com/api/?";
const JOB_QUERY_PARAMS = {
api_key: "5634cc46389d0d872723b8c46fba672c",
method: "aj.jobs.search",
perpage: "10",
format: "json"
};
const buildJobsUrl = () => {
const query = qs.stringify({ ...JOB_QUERY_PARAMS });
return `${JOB_ROOT_URL}${query}`;
};
export const fetchJobs = (region, callback) => async dispatch => {
try {
const url = buildJobsUrl();
let { data } = await axios.get(url);
dispatch({ type: FETCH_JOBS, payload: data });
callback();
} catch (e) {
console.log(e);
}
};
export const likeJob = job => {
return {
payload: job,
type: LIKE_JOB
};
};
and reducer:
import { FETCH_JOBS } from "../actions/types";
const INITIAL_STATE = {
listing: []
};
export default function(state = INITIAL_STATE, action) {
switch (action.type) {
case FETCH_JOBS:
return action.payload;
default:
return state;
}
}
and the combineReducer is setup correctly as well:
import { combineReducers } from "redux";
import auth from "./auth_reducer";
import jobs from "./jobs_reducer";
import likedJobs from "./likes_reducer";
export default combineReducers({
auth,
jobs,
likedJobs
});
The listing: [] is based off the structure of the response I get back. When I console.log(data);, the actual data I care about is inside of listing property. So I set up the INITIAL_STATE to default listing to be an empty array with the intent to ensure I could map over the array and not worry about the case where I have not yet fetched the list of jobs. When I go to the API endpoint directly you can see it below:
I think the problem is simply that this.props.jobs is undefined. Your initial state is defined as { listing: [] }, however you mapStateToProps do { jobs: ... }.
Try changing initialState to { jobs: [] }, so that it always work on your first rendering.
I think your mapStateToProps should be:
mapStateToProps = (state) => {
return { jobs: listings.listing }
}
EDIT
Actually, it could be even better if you 'name' your state correctly in your reducer, like:
const INITIAL_STATE = { jobs: [] }
export default function(state = INITIAL_STATE, action) {
switch (action.type) {
case FETCH_JOBS:
const jobs = action.payload.listings.listing
return { ...state, jobs };
default:
return state;
}
}
Then in your mapStateToProps:
mapStateToProps = ({ jobs }) => {
return { jobs }
}
The issue is in your reducer. Please refer the below changes:
import { FETCH_JOBS } from "../actions/types";
const INITIAL_STATE = {
listing: []
};
export default function(state = INITIAL_STATE, action) {
switch (action.type) {
case FETCH_JOBS:
const { listings } = action.payload
return {...state, listing: listings.listing}
default:
return state;
}
}
Hope this will help.
function mapStateToProps({ jobs }) {
return { jobs: jobs.listing };
}
the above is making confusion for you try the below one
try to put
function mapStateToProps( state ) {
return { jobs: state.jobs.listing };
}
as you have defined your reducer as follow
export default combineReducers({
auth,
jobs,
likedJobs
});
jobs is your variable to access jobs reducer