Skip to content

Some thoughts about Redux's UX #2510

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Maclay74 opened this issue Jul 12, 2022 · 3 comments
Closed

Some thoughts about Redux's UX #2510

Maclay74 opened this issue Jul 12, 2022 · 3 comments

Comments

@Maclay74
Copy link

Hey.

I've been using redux for years, I liked it even when everybody were saying that it's outdated and stuff.
Then the game has changed with RTK query, I tried to use it... Well, it's definitely better in some way, but now it doesn't feel consistent anymore.

I'm working on an app. It requires login to use it, so it's the first thing I implement.

So I need to make a request to the server with user's credentials, and then update the state (set isAuthorized to true), if everything is fine.
Well, okay, for requests RTK query is fine.

export const authApi = baseApi.injectEndpoints({
  endpoints: build => ({
    login: build.mutation({
      query: ({code}) => ({
        url: 'auth/login',
        method: 'GET',
        params: {code},
      }),
    }),
  }),
});

I cached token somewhere?.. How do I subscribe to this change? How do I remove it from there?
I don't actually have control over the state. Alright, I can solve it with slice and extra reducer, subscribed for it.

export const authSlice = createSlice({
  name: 'auth',
  initialState: {isAuthorized: false},
  reducers: {},
  extraReducers: builder => {
    builder.addMatcher(
      authApi.endpoints.login.matchFulfilled,
      (state, {payload}) => {
        state.isAuthorized = true;
      },
    );
  },
});

I ended up storing user's token twice in the store. Ouch.
Now, out of sudden, I need to log out. I can't keep the same logic, I don't need a request this time, just change the state.
I created reducer for this, but at this point I got myself lost in this mess. I have logic in few different places, they depend on each other and it's only the auth flow. How do I maintain it in future?

Documentation stays that
Redux Toolkit does not currently provide any special APIs or syntax for writing thunk functions. In particular, they cannot be defined as part of a createSlice() call. You have to write them separate from the reducer logic, exactly the same as with plain Redux code.

But why? Why can't we just have something like this

const auth = baseApi.createReduxSlice({
  initialState: {isAuthorized: false},
  name: 'auth',
  endpoints: builder => ({
    login: builder.mutation(async (payload, state) => {
      const response = await builder.query('auth/login', 'GET', payload);
      state.isAuthorized = true;
    }),
    logout: builder.reducer((payload, state) => {
      state.isAuthorized = false;
    }),
  })
})

Maybe I'm missing something, but this would cover all the bases for me. This is the way I worked with Redux before, it was working for any case!

If you know any wrapper or something, that can just aggregate everythng in one place, please let me know.
If you can explain me what I am doing wrong and change the way I think, It's even more appreciated.

Thanks.

@phryneas
Copy link
Member

phryneas commented Jul 12, 2022

This is the way I worked with Redux before, it was working for any case!

Well, but it wasn't, was it? Before, you had to write every little detail by hand. You can still do that - or you save tons of code, but opt into RTKQ.
The tradeoff is: this only works well if it is abstracted away separately - otherwise the risk of your logic colliding with the RTKQ logic would just be too high - and we would never be able to add any new features in the future, since that new feature might collide with some code a library consumer has written themselves. Every change would be potentially breaking.

I ended up storing user's token twice in the store. Ouch.

Well, no. You stored it once.
In addition to that, a caching mechanism also held it for a while - but only until another login action was fired or the component executing the mutation was unmounted.

=> RTK Query is a cache mechanism. Data is in there for the amount of time RTK Query's features need that data. Then it is removed. That is exactly why you have to save it to the side if you want to have it accessible for a longer amount of time.

Redux Toolkit does not currently provide any special APIs or syntax for writing thunk functions. In particular, they cannot be defined as part of a createSlice() call. You have to write them separate from the reducer logic, exactly the same as with plain Redux code.
But why?

We tried. It ends up being a mess of circular dependencies that do not work with TypeScript. Since how things in the ecosystem are moving right now, TypeScript is at the core of almost every serious project, we cannot add a feature that wouldn't work with TypeScript. See #637. Also, even if you could define thunks from a slice, that wouldn't mean that you could define queries and mutations there. You are conflating thunks with queries and mutations, which are an even much more complex thing.

Bottom line: It's just bottom line impossible to do any of those things, given the big number of users and use cases the library has. If we were to add functionality like this it would severely limit how RTK & RTKQ could be used.

@markerikson
Copy link
Collaborator

There's really like 5 different questions being asked here:

  • How does RTK Query work? How does it store the cached data? How do you access that data later?
  • How do you use RTKQ conceptually? How do you "think in caching"?
  • What's the difference between createAsyncThunk, a "basic" thunk written by hand, and an RTKQ mutation or query?
  • What are the TS limitations that have prevented us from shipping a way to define thunks inside of a createSlice call?
  • And in this specific case, how do you deal with an auth token?

Answering them in order:

RTK Query Implementation

RTK Query is built out of all the same tools, techniques, and principles that Redux has always used: thunks for making async requests, dispatching actions based on request success/failure, a reducer that stores the cached data and tracks loading state, and a custom middleware that helps manage cache entry lifetimes.

This is all the same kind of code that you normally would have written yourself... except now you don't have to write any of it yourself :)

RTK Query Data Storage

Cache entries are stored in the state slice managed by the cache reducer. This is normally in state.api. Query cache entries are normally kept under a .queries object, keyed by endpoint name + serialized cache args. For example, if I have useGetPokemonQuery('pikachu'), the resulting cache entry would be stored at state.api.queries["getPokemon('pikachu')"].

You could access those with any normal Redux selector or state lookup, like useSelector(state => state.api.queries["getPokemon('pikachu')"]). But, since that's a pain to remember, and RTKQ is supposed to be an abstraction, you would instead let the generated useGetPokemonQuery hook do that work for you. The API endpoints can also generate selector functions that know how to retrieve that cache entry.

Thinking in RTKQ and Cache Entry Management

When you use RTKQ, you should think differently about how you work with the data. Stop thinking in "actions" and "dispatching" and "managing state". Instead, it's "is this data cached? What parts of the UI need this cached data? What forces the app to re-fetch this data?"

When you use the generated hooks, RTKQ automatically tracks how many components need this data. If that number goes to 0, RTKQ will wait a bit, and then remove the data from the cache if there's still no component needing it. On the flip side, you can set up mutations to invalidate tags attached to query endpoints, and running the mutation forces RTKQ to re-fetch the query data.

Thunks

A basic thunk looks like:

function myThunk(id: numberOrWhatever) {
  return (dispatch, getState) => {
     // _any_ logic you want here 
  }
}

That thunk could be sync or async. It might dispatch actions, read state, or anything else.

For data fetching and async requests, Redux has always encouraged a pattern of "dispatch an action before the request, dispatch different actions on success or failure after the request". So, we made createAsyncThunk to simplify that pattern.

With a "basic" thunk, you can put literally any code inside, so there's no helper function we can make for that (and there's no reason to make a helper function).

As Lenz said, we've wanted to add a way to define thunks inside of createSlice for years... but the TS types are just too complicated and there's no good way to do it.

Thunks vs RTKQ Queries/Mutations

Technically, api.endpoints.getPokemon.initiate is a createAsyncThunk-based thunk. So is const [updateUser] = useUpdateUserMutation().

But, a "slice" is a very different thing than an "API definition with endpoints". So, no, we can't add something like builder.mutation inside of createSlice. For that matter, not everyone uses RTK Query, and we don't want people to add bundle size that they don't need.

Auth Tokens

I'll admit that I haven't had to deal with these much myself. But in general, it sounds like you've already got a mutation that tells the server you're logged in.

The issue is that "mutations" aren't designed to cache data on the client. A mutation is for sending a message to the server. We do keep some data in the Redux store temporarily while the mutation request is in progress, but conceptually "queries" are what you use to cache data.

One option would be to have a getAuthToken query, have the login mutation invalidate that query, and force an automatic fetch of the token after login succeeds. You might also be able to programmatically update the cache entry (although we don't currently have a way to add a cache entry that doesn't exist yet - we're adding that for RTK 1.9).

Assuming you're letting RTKQ handle fetching the token, then you'd access it via a const { data : authToken } = useGetAuthTokenQuery() hook in a component, or via a generated selector if needed somewhere else.

If the token does come back in the mutation response, then sure, listening for it in a slice's extraReducers is also a valid way to save the token value.

Hopefully all this helps clarify things.

@Maclay74
Copy link
Author

Maclay74 commented Jul 13, 2022

First of all, thanks for such great explanation and examples - your point is very clear.

Now I think that RTKQ is perfect for the cases when UI needs some data or update something. It's 99% of the app.

But authentication is different. Both query and mutation don't really fit there - mutation should actually change something, and token from query shouldn't be cached.

Taking into account everything you said, I see two ways for me:

  1. Keep everything the way it is. RTKQ mutation to get token, matcher saves it to the slice, components that require authorization subscribed to this slice.
  2. Rewrite everything to createAsyncThunk and have two actions in one place and handle http requests myself.

Anyway, I'm using RTKQ for the rest, it's obvious.

@markerikson markerikson closed this as not planned Won't fix, can't repro, duplicate, stale Jul 26, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants